- 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
182 lines
8.4 KiB
JavaScript
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);
|
|
}
|