Lands Sage's sage/row66-r5 at7ffb640ac, round 4 (r4d82c830779) rebased ontoa36523713after row 197, approved by Dewey (comment 30085) and Filbert (30086) on manifest 6cfd812da4ca (23 paths, rev 2905). The rebase had no conflict. The range-diff is = except one pi-session.test.mjs hunk header. Patch-id without agents/ is d67275ac4251 on7b9737953,a36523713and this commit's parent. Gate (mos-1186): unslop, harness, cli and every scripts/test-*.sh green on r4d, harness 149/0 again on r5. Landing sessions on a scratch root from the candidate tree: Claude Code r4ab24630d323 showed the note, the injected notice and counts checked 2, flagged 1, notices 1. Pi r1dd94171bede is launch only until #383 (Vikunja #454).
142 lines
5.9 KiB
JavaScript
142 lines
5.9 KiB
JavaScript
#!/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=<path>, 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 };
|