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
This commit is contained in:
2026-09-03 11:24:56 -05:00
parent 0273a84549
commit 7db4c5c2ed
17 changed files with 1028 additions and 20 deletions
+228
View File
@@ -0,0 +1,228 @@
"use strict";
// Tests for unslop-check.js. Run: node test-unslop-check.js
// Exit 0 = all pass. Cases include a red control (slop must fail) and a green
// control (clean prose must pass) per evidence discipline.
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { checkText, stripCode, loadLists } = require("./unslop-check.js");
const SLOP = `Certainly! Let me delve into the evolving tapestry of database technology — its truly “pivotal” — and intricate.
In order to understand it — we should leverage this interplay of systems — deeply. I hope this helps!`;
const CLEAN = `The loader parses the file and validates each row. Rows that fail are logged
and skipped. We measured a range from 1 to 10 seconds. Use "straight quotes" and
commas, not dashes. That is the whole finding.`;
// Code-stripping control: banned words inside code must not count.
const WITH_CODE = [
"The config uses `utilize=false` internally.",
"```",
"delve tapestry — pivotal",
"```",
"The config file sets one flag. It is parsed at startup.",
].join("\n");
const results = [];
function t(name, fn) {
try { fn(); results.push([name, true]); } catch (e) { results.push([name, false]); console.error(`FAIL ${name}: ${e.message}`); }
}
t("slop fixture is flagged (red control)", () => {
const r = checkText(SLOP);
assert.ok(!r.clean, "slop must not be clean");
const details = r.findings.map((f) => f.detail).join("; ");
assert.ok(r.findings.some((f) => f.detail.includes("delve")), `delve missing: ${details}`);
assert.ok(r.findings.some((f) => f.detail.includes("tapestry")), `tapestry missing: ${details}`);
assert.ok(r.findings.some((f) => f.detail.includes("pivotal")), `pivotal missing: ${details}`);
assert.ok(r.findings.some((f) => f.detail.includes("em dash")), `em dash missing: ${details}`);
assert.ok(r.findings.some((f) => f.detail.includes("curly")), `curly missing: ${details}`);
assert.ok(r.findings.some((f) => f.detail.includes("in order to")), `in order to missing: ${details}`);
assert.ok(r.findings.some((f) => f.detail.includes("i hope this helps")), `chatbot phrase missing: ${details}`);
assert.ok(r.findings.some((f) => f.detail.includes("certainly")), `certainly missing: ${details}`);
});
t("clean fixture passes (green control)", () => {
const r = checkText(CLEAN);
assert.deepStrictEqual(r.findings, [], `unexpected findings: ${JSON.stringify(r.findings)}`);
});
t("numeric range is not a false range flag", () => {
const r = checkText(CLEAN);
assert.ok(!r.findings.some((f) => f.rule === "pattern"), "must not flag numeric ranges");
});
t("code blocks and inline code are stripped", () => {
const r = checkText(WITH_CODE);
assert.deepStrictEqual(r.findings, [], `code leaked into check: ${JSON.stringify(r.findings)}`);
});
t("stripCode removes fenced and inline code", () => {
const s = stripCode("a `x — y` b\n```\ndelve\n```\nc");
assert.ok(!s.includes("delve"), "fenced code not stripped");
assert.ok(!s.includes("—"), "inline code not stripped");
assert.ok(s.includes("a") && s.includes("b") && s.includes("c"), "prose lost");
});
t("not-just-but pattern is detected", () => {
const r = checkText("This is not just a cache but a coordination layer.");
assert.ok(r.findings.some((f) => f.rule === "pattern"), "pattern missed");
});
t("light dash use is not flagged (below threshold)", () => {
const prose =
"The loader parses each row and validates it against the schema. Rows that fail " +
"are logged — with their line numbers — and skipped. The operator reviews the log " +
"daily and reconciles the rejects against the source system by hand, which takes " +
"a few minutes and has never once produced a discrepancy worth acting on.";
const r = checkText(prose);
assert.ok(!r.findings.some((f) => f.detail.includes("dash")), "2 dashes in ~330 chars must not flag");
});
t("dash overuse is flagged (above threshold)", () => {
const r = checkText("One — two — three — four. That is the whole sentence.");
assert.ok(r.findings.some((f) => f.detail.includes("em dash")), "4 dashes in 50 chars must flag");
});
// ── C1: lists.json machine source ──────────────────────────────────────
// The committed lists are the single source of truth; these tests pin the
// file's validity, its shape, and the loader's fail-closed behavior.
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "unslop-c1-"));
const tmp = (n) => path.join(tmpdir, n);
function brokenVariant(mutate) {
const l = JSON.parse(JSON.stringify(loadLists()));
mutate(l);
return l;
}
function writeTmp(name, data) {
const f = tmp(name);
fs.writeFileSync(f, typeof data === "string" ? data : JSON.stringify(data));
return f;
}
t("lists.json (the real file) validates and is pinned in size", () => {
const l = loadLists();
assert.strictEqual(l.version, 1);
// Counts pin the migration: 16 words, 17 phrases, 3 punct, 1 pattern moved
// from the old inline constants. Changing a count means changing this test
// too, consciously.
assert.strictEqual(l.words.length, 16, "word count drifted");
assert.strictEqual(l.phrases.length, 17, "phrase count drifted");
assert.strictEqual(l.punct.length, 3, "punct count drifted");
assert.strictEqual(l.patterns.length, 1, "pattern count drifted");
assert.ok(l.convention.length > 50, "mention convention must be present");
assert.ok(l.punctPolicy.length > 50, "punct divergence policy must be present");
for (const e of [...l.words, ...l.phrases, ...l.punct, ...l.patterns]) {
assert.ok(e.source && e.source.trim(), `entry missing source: ${JSON.stringify(e)}`);
}
});
t("loader rejects an empty file", () => {
const f = writeTmp("empty.json", "");
assert.throws(() => loadLists(f), /empty file/);
});
t("loader rejects unparseable JSON", () => {
const f = writeTmp("bad.json", "{nope");
assert.throws(() => loadLists(f), /unparseable/);
});
t("loader rejects a missing file", () => {
assert.throws(() => loadLists(tmp("does-not-exist.json")), /cannot read/);
});
t("loader rejects missing keys", () => {
const f = writeTmp("nokeys.json", { version: 1 });
assert.throws(() => loadLists(f), /missing key/);
});
t("loader rejects an emptied word list", () => {
const f = writeTmp("emptywords.json", brokenVariant((l) => { l.words = []; }));
assert.throws(() => loadLists(f), /words must be a non-empty array/);
});
t("loader rejects entries without provenance", () => {
const f = writeTmp("nosource.json", brokenVariant((l) => { delete l.phrases[0].source; }));
assert.throws(() => loadLists(f), /source/);
});
t("loader rejects duplicate values", () => {
const f = writeTmp("dup.json", brokenVariant((l) => { l.words.push({ ...l.words[0] }); }));
assert.throws(() => loadLists(f), /duplicate/);
});
t("loader rejects a non-compiling pattern regex", () => {
const f = writeTmp("badregex.json", brokenVariant((l) => { l.patterns[0].regex = "("; }));
assert.throws(() => loadLists(f), /does not compile/);
});
t("CLI exits 2 on broken lists (red control)", () => {
const f = writeTmp("cli-broken.json", "");
const r = spawnSync(process.execPath, [path.join(__dirname, "unslop-check.js")], {
input: "some prose",
encoding: "utf8",
env: { ...process.env, UNSLOP_LISTS: f },
});
assert.strictEqual(r.status, 2, `expected exit 2, got ${r.status} (stderr: ${r.stderr})`);
assert.ok(r.stderr.includes("lists.json invalid"), `stderr must name the fault: ${r.stderr}`);
});
t("CLI honors UNSLOP_LISTS for a valid file (green control)", () => {
const r = spawnSync(process.execPath, [path.join(__dirname, "unslop-check.js")], {
input: "plain prose with no tells at all",
encoding: "utf8",
env: { ...process.env, UNSLOP_LISTS: path.join(__dirname, "lists.json") },
});
assert.strictEqual(r.status, 0, `expected exit 0, got ${r.status} (stderr: ${r.stderr})`);
});
// ── Review follow-up (rev-code-01, 2026-08-19): F1, F2, F3, S3 ────────
t("loader rejects non-finite thresholds (F1)", () => {
// Infinity cannot round-trip JSON.stringify, so the fixture is a raw string
// edit of the real file — exactly the hand-edit that produced the finding.
const real = fs.readFileSync(path.join(__dirname, "lists.json"), "utf8");
const f = writeTmp("inf-threshold.json", real.replace('"punctMinCount": 3', '"punctMinCount": 1e999'));
assert.ok(real !== fs.readFileSync(f, "utf8") || !real.includes('"punctMinCount": 3'), "fixture mutation did not apply; test is vacuous");
assert.throws(() => loadLists(f), /finite/);
const f2 = writeTmp("inf-density.json", real.replace('"punctDensityPer1k": 1.0', '"punctDensityPer1k": 1e999'));
assert.throws(() => loadLists(f2), /finite/);
});
t("curly-apostrophe phrase rendering is flagged (F2 red control)", () => {
const r = checkText("Here\u2019s the honest truth about the deploy.");
assert.ok(!r.clean, "curly apostrophe must not defeat phrase matching");
assert.ok(r.findings.some((x) => x.detail.includes("here's the honest truth")), `main entry must match, got: ${JSON.stringify(r.findings)}`);
});
t("curly apostrophes still fire the punct rule alongside phrases (F2 ordering)", () => {
// Normalization for phrases must not eat the punct signal: four curly
// quotes in short text must flag punct, not only the phrase.
const r = checkText("It\u2019s \u2019one\u2019 \u2019two\u2019 \u2019three\u2019 \u2019four\u2019 done.");
assert.ok(r.findings.some((f) => f.rule === "punct"), `punct must fire on original text: ${JSON.stringify(r.findings)}`);
});
t("loader rejects non-lowercase phrase values (F3)", () => {
const f = writeTmp("cap-phrase.json", brokenVariant((l) => { l.phrases[0].value = "Worth Stating Plainly"; }));
assert.throws(() => loadLists(f), /lowercase/);
});
t("CLI exits 2 on unreadable input file (S3)", () => {
const r = spawnSync(process.execPath, [path.join(__dirname, "unslop-check.js"), tmp("definitely-absent.txt")], {
encoding: "utf8",
});
assert.strictEqual(r.status, 2, `expected exit 2, got ${r.status} (stderr: ${r.stderr})`);
assert.ok(r.stderr.includes("cannot read input"), `stderr must name the fault: ${r.stderr}`);
});
let failed = 0;
for (const [name, ok] of results) { console.log(`${ok ? "PASS" : "FAIL"} ${name}`); if (!ok) failed++; }
console.log(`${results.length - failed}/${results.length} passed`);
try { fs.rmSync(tmpdir, { recursive: true, force: true }); } catch {}
process.exit(failed ? 1 : 0);