Files
stack/tools/unslop-hook/unslop-check.js
T
jason.woltje 7db4c5c2ed feat(agent): interactive TUI launcher + identity + TOOLS.md (#35)
- scripts/agent.sh <name>: launches interactive pi TUI in the container
  with contracts + optional mission + agent identity + named session +
  optional workspace/tools; the Mosaic alternative to vanilla pi
- pi adapter: MOSAIC_INTERACTIVE branch (clean TUI, no -p, no initial
  prompt); headless exec rebuilt via positional args (no word-splitting
  on the request); MOSAIC_AGENT_NAME optional in headless
- loader: AGENT IDENTITY section when the launcher names the agent
- compose: fixed command removed (request defaults live in run-agent.sh);
  MOSAIC_INTERACTIVE/MOSAIC_AGENT_NAME passthrough
- docs/TOOLS.md: full on-demand tool reference; AGENTS.md routes to it
- RELEASE -> 0.0.8 (container change); build verified

Closes #35
2026-09-03 11:24:56 -05:00

182 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
"use strict";
// unslop-check — mechanical AI-tell checker (ms-unslop subset + SYSTEM.md phrase bans).
// Plain JS, no deps, so pi extensions (jiti) and Claude Code hook scripts (node CLI)
// share one implementation.
//
// The lists live in lists.json beside this file: committed machine source with
// per-entry provenance (which ms-unslop pattern or SYSTEM.md rule each entry
// mechanizes), the mention convention, and the punct thresholds. The loader
// hard-fails closed: an empty, unparseable, or schema-invalid lists.json throws,
// and the CLI exits 2 so a broken gate is never mistaken for a clean verdict.
//
// CLI: node unslop-check.js <file> (or stdin)
// exit 0 = clean, exit 1 = violations found (findings printed as JSON),
// exit 2 = gate broken (lists.json missing/empty/invalid; error on stderr).
// Env: UNSLOP_LISTS=<path> overrides the lists.json location (testing; reuse by
// other harnesses sharing this file).
//
// Provenance note (2026-08-19): the inline lists this file carried before C1
// moved to lists.json unchanged — 16 words, 17 phrases, 3 punct rules, 1 pattern.
// The suite pins those counts; a list edit without a test edit is a drift signal.
const fs = require("node:fs");
const path = require("node:path");
function stripCode(text) {
// Fenced blocks (``` or ~~~), then inline code spans. Code is quoted material,
// not the agent's prose style. This is also the mention convention: a banned
// item quoted as inline code is a mention and must not flag (see lists.json).
return text
.replace(/```[\s\S]*?```/g, " ")
.replace(/~~~[\s\S]*?~~~/g, " ")
.replace(/`[^`\n]*`/g, " ");
}
// ── lists.json loading and validation ─────────────────────────────────────────
function validateLists(data) {
const fail = (why) => { throw new Error(`lists.json invalid: ${why}`); };
if (typeof data !== "object" || data === null || Array.isArray(data)) fail("top level must be an object");
for (const k of ["version", "convention", "punctPolicy", "thresholds", "words", "phrases", "punct", "patterns"]) {
if (!(k in data)) fail(`missing key: ${k}`);
}
if (typeof data.version !== "number" || data.version < 1) fail("version must be a number >= 1");
for (const k of ["convention", "punctPolicy"]) {
if (typeof data[k] !== "string" || !data[k].trim()) fail(`${k} must be a non-empty string`);
}
const th = data.thresholds;
if (typeof th !== "object" || th === null) fail("thresholds must be an object");
// Number.isFinite, not just typeof: JSON.parse of 1e999 yields Infinity, which
// passes typeof-number and would silently disable the punct gate (review F1).
if (!Number.isFinite(th.punctMinCount) || th.punctMinCount < 1) fail("thresholds.punctMinCount must be a finite number >= 1");
if (!Number.isFinite(th.punctDensityPer1k) || !(th.punctDensityPer1k > 0)) fail("thresholds.punctDensityPer1k must be a finite number > 0");
const seen = new Set();
const checkEntries = (arr, kind, extra) => {
if (!Array.isArray(arr) || arr.length === 0) fail(`${kind} must be a non-empty array`);
arr.forEach((e, i) => {
const at = `${kind}[${i}]`;
if (typeof e !== "object" || e === null) fail(`${at} must be an object`);
if (typeof e.value !== "string" || !e.value.trim()) fail(`${at}.value must be a non-empty string`);
if (typeof e.source !== "string" || !e.source.trim()) fail(`${at}.source must be a non-empty string (pattern id or system-md)`);
if (extra) extra(e, at, fail);
if (seen.has(`${kind}:${e.value}`)) fail(`duplicate ${kind} value: ${e.value}`);
seen.add(`${kind}:${e.value}`);
});
};
checkEntries(data.words, "words");
checkEntries(data.phrases, "phrases", (e, at, fail) => {
// Phrase matching splits a lowercased haystack, so an uppercase letter in a
// phrase value is a silently dead rule (review F3). Reject, do not silently
// normalize: list edits should fail loud (D-a).
if (e.value !== e.value.toLowerCase()) fail(`${at}.value must be lowercase; phrase matching lowercases the haystack: ${e.value}`);
});
checkEntries(data.punct, "punct", (e, at, fail) => {
if (typeof e.label !== "string" || !e.label.trim()) fail(`${at}.label must be a non-empty string`);
if (!Array.isArray(e.chars) || e.chars.length === 0 || !e.chars.every((c) => typeof c === "string" && c.length === 1)) {
fail(`${at}.chars must be a non-empty array of single-char strings`);
}
});
checkEntries(data.patterns, "patterns", (e, at, fail) => {
if (typeof e.regex !== "string" || !e.regex.trim()) fail(`${at}.regex must be a non-empty string`);
if (typeof e.flags !== "string") fail(`${at}.flags must be a string`);
if (typeof e.detail !== "string" || !e.detail.trim()) fail(`${at}.detail must be a non-empty string`);
try { new RegExp(e.regex, e.flags); } catch (err) { fail(`${at}.regex does not compile: ${err.message}`); }
});
return data;
}
let cache = null;
function loadLists(filePath) {
if (cache && !filePath) return cache;
const p = filePath || process.env.UNSLOP_LISTS || path.join(__dirname, "lists.json");
let raw;
try {
raw = fs.readFileSync(p, "utf8");
} catch (e) {
throw new Error(`lists.json invalid: cannot read ${p}: ${e.message}`);
}
if (!raw.trim()) throw new Error(`lists.json invalid: empty file: ${p}`);
let data;
try {
data = JSON.parse(raw);
} catch (e) {
throw new Error(`lists.json invalid: unparseable JSON: ${e.message}`);
}
const validated = validateLists(data);
if (!filePath) cache = validated;
return validated;
}
// ── checker ───────────────────────────────────────────────────────────────────
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function checkText(raw) {
const lists = loadLists();
const text = stripCode(String(raw));
// Phrase haystack: lowercased, then curly apostrophes normalized to ASCII.
// This must be a SEPARATE string from `text`: punct counting reads the
// original, so curly quotes still fire the punct rule (review F2 ordering).
const phraseHay = text.toLowerCase().replace(/[\u2018\u2019]/g, "'");
const findings = [];
for (const w of lists.words) {
const re = new RegExp("\\b" + escapeRegex(w.value) + "\\b", "gi");
const count = (text.match(re) || []).length;
if (count > 0) findings.push({ rule: "word", detail: `banned word "${w.value}" x${count}`, count });
}
for (const p of lists.phrases) {
const count = phraseHay.split(p.value).length - 1;
if (count > 0) findings.push({ rule: "phrase", detail: `phrase "${p.value}" x${count}`, count });
}
// Density-gated punctuation. The deliberate divergence from SYSTEM.md's
// outright em-dash ban is documented in lists.json punctPolicy, not only here.
for (const pc of lists.punct) {
let count = 0;
for (const ch of pc.chars) count += text.split(ch).length - 1;
if (count < lists.thresholds.punctMinCount) continue;
if (count / Math.max(text.length, 1) * 1000 < lists.thresholds.punctDensityPer1k) continue;
findings.push({ rule: "punct", detail: `${pc.label} x${count} (density-gated)`, count });
}
for (const pt of lists.patterns) {
const flags = pt.flags.includes("g") ? pt.flags : pt.flags + "g";
const m = text.match(new RegExp(pt.regex, flags));
const count = m ? m.length : 0;
if (count > 0) findings.push({ rule: "pattern", detail: `"${pt.detail}" x${count}`, count });
}
return { clean: findings.length === 0, findings, charsChecked: text.length };
}
module.exports = { checkText, stripCode, loadLists, validateLists };
if (require.main === module) {
let input;
try {
input = process.argv[2] ? fs.readFileSync(process.argv[2], "utf8") : fs.readFileSync(0, "utf8");
} catch (e) {
// An unreadable input must not exit 1: that is the violations code, and a
// wrapper keying on rc alone would report slop-free for a file it never
// read (review S3).
console.error(`unslop-check: cannot read input: ${e.message}`);
process.exit(2);
}
let result;
try {
result = checkText(input);
} catch (e) {
if (String(e.message).startsWith("lists.json invalid")) {
console.error(`unslop-check: ${e.message}`);
process.exit(2);
}
throw e;
}
console.log(JSON.stringify(result, null, 2));
process.exit(result.clean ? 0 : 1);
}