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
+4
View File
@@ -69,9 +69,13 @@ green at every step. Not production software — a proven foundation.
`verify.sh` · `run-task.sh run <task.json>` · `release.sh `verify.sh` · `run-task.sh run <task.json>` · `release.sh
package|activate|rollback|status` · `reset.sh` (**danger**: wipes the data package|activate|rollback|status` · `reset.sh` (**danger**: wipes the data
root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune` · root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune` ·
`agent.sh <name>` (interactive TUI agent) ·
suites: `test-config.sh`, `test-task.sh`, `test-release.sh`, suites: `test-config.sh`, `test-task.sh`, `test-release.sh`,
`test-conductor.sh`. `test-conductor.sh`.
Full reference — usage, fields, exit codes, safety notes:
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
## Data map (canon) ## Data map (canon)
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never - `~/.config/mosaic-dev/config.json` — system config (user-authored; never
+1 -1
View File
@@ -1 +1 @@
0.0.7 0.0.8
+3 -1
View File
@@ -6,7 +6,9 @@
set -eu set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; } [ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; } if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
fi
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; } [ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2 echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
+30 -7
View File
@@ -2,16 +2,24 @@
# Pi adapter: implements the Mosaic adapter contract for the pinned # Pi adapter: implements the Mosaic adapter contract for the pinned
# @earendil-works/pi-coding-agent CLI. # @earendil-works/pi-coding-agent CLI.
# #
# Contract: see /opt/mosaic/adapters/README.md. stdout = response only. # Contract: see /opt/mosaic/adapters/README.md.
# Headless (default): stdout = response only; stderr = diagnostics; exit 0.
# Interactive (MOSAIC_INTERACTIVE=1): full pi TUI on the attached terminal.
set -eu set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; } [ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; } [ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
# MOSAIC_AGENT_NAME is optional in headless mode (identity section is then
# omitted); interactive launches always set it via scripts/agent.sh.
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}" : "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
: "${PI_MODEL:?pi adapter: PI_MODEL is required}" : "${PI_MODEL:?pi adapter: PI_MODEL is required}"
INTERACTIVE="${MOSAIC_INTERACTIVE:-}"
if [ "$INTERACTIVE" != "1" ]; then
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
fi
# Workspace (M5): run inside the provided workspace when present. # Workspace (M5): run inside the provided workspace when present.
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
mkdir -p "$MOSAIC_WORKSPACE" mkdir -p "$MOSAIC_WORKSPACE"
@@ -39,13 +47,25 @@ fi
TOOLS_FLAG="--no-tools" TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS" [ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# Mode (M13): interactive TUI or one-shot print.
PRINT_MODE="-p"
REQUEST_ARG=""
if [ "$INTERACTIVE" = "1" ]; then
PRINT_MODE=""
else
REQUEST_ARG="$MOSAIC_REQUEST"
fi
# All flags documented in the pi package README (CLI Reference): # All flags documented in the pi package README (CLI Reference):
# -p/--print noninteractive: print the response and exit # -p/--print one-shot mode: print the response and exit (omitted in
# interactive TUI mode)
# --system-prompt replace the default prompt with the generated one # --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes # --no-* no ambient context/skills/extensions/templates/themes
# --no-session ephemeral; TOOLS_FLAG per capabilities # SESSION_FLAGS ephemeral | persistent | forked (per env)
# TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry) # --offline no startup network operations (update checks/telemetry)
exec pi \ PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
set -- \
--offline \ --offline \
--no-extensions \ --no-extensions \
--no-skills \ --no-skills \
@@ -56,5 +76,8 @@ exec pi \
$SESSION_FLAGS \ $SESSION_FLAGS \
--provider "$PI_PROVIDER" \ --provider "$PI_PROVIDER" \
--model "$PI_MODEL" \ --model "$PI_MODEL" \
--system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \ --system-prompt "$PROMPT_CONTENT"
-p "$MOSAIC_REQUEST" # One-shot mode appends -p and the request (both safely quoted);
# interactive mode appends nothing - clean TUI.
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
exec pi "$@"
+6 -3
View File
@@ -21,6 +21,9 @@ services:
# Persistent named session dir + optional fork source (M6/M11) # Persistent named session dir + optional fork source (M6/M11)
MOSAIC_SESSION_DIR: ${MOSAIC_SESSION_DIR:-} MOSAIC_SESSION_DIR: ${MOSAIC_SESSION_DIR:-}
MOSAIC_SESSION_FORK: ${MOSAIC_SESSION_FORK:-} MOSAIC_SESSION_FORK: ${MOSAIC_SESSION_FORK:-}
# Interactive TUI mode + agent identity (M13, set by scripts/agent.sh)
MOSAIC_INTERACTIVE: ${MOSAIC_INTERACTIVE:-}
MOSAIC_AGENT_NAME: ${MOSAIC_AGENT_NAME:-}
# mock adapter only: verbatim response for deterministic seam tests # mock adapter only: verbatim response for deterministic seam tests
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-} MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
# Documented container auth alternative: provider API key via # Documented container auth alternative: provider API key via
@@ -34,6 +37,6 @@ services:
# Runtime credential only: pi auth file mounted READ-ONLY. # Runtime credential only: pi auth file mounted READ-ONLY.
# Never copied into the image. # Never copied into the image.
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro - ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
# One-shot: the exact startup verification request. It deliberately # Headless runs: the request is passed as command args by the launchers
# does NOT contain the expected marker MOSAIC_HELLO_OK. # (run-task.sh) or defaults inside run-agent.sh (hello/verify). Never a
command: ["Return your startup marker and nothing else."] # fixed command here - interactive runs (scripts/agent.sh) need no args.
+85
View File
@@ -0,0 +1,85 @@
# TOOLS.md — command and tool reference
On-demand reference for agent sessions (conductors, bootstrapping agents,
reviewers). `AGENTS.md` routes here; this file carries the depth: usage,
inputs/outputs, exit codes, and safety notes for every entry point.
Reading guide: all entry points are `scripts/*.sh` (bash) or invoked via
`node scripts/mosaic-task.mjs` (node). Every script fails closed — missing
or invalid configuration/policy refuses the operation with a nonzero exit
and changes nothing.
## Lifecycle
| Command | Purpose | Notes |
|---|---|---|
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
## Tasks (missions, runs, evidence)
| Command | Purpose | Notes |
|---|---|---|
| `scripts/run-task.sh run <task.json>` | Execute a task | Immutable run record under `<dataRoot>/runs/` |
| `scripts/run-task.sh validate <task.json>` | Strict validation | Writes nothing |
| `node scripts/mosaic-task.mjs show <runId>` | Inspect a run | Full record + snapshots + artifacts |
| `node scripts/mosaic-task.mjs list` | List runs | task/workspace/session columns |
| `node scripts/mosaic-task.mjs retry <runId>` | Re-execute a run's snapshot | New run dir; `retriedFrom` lineage recorded |
| `node scripts/mosaic-task.mjs prune [--keep=N] [--yes]` | Retention | Dry-run default; receipt in `runs/.pruned.log` |
Task fields: `prompt` (required), `mission` (path), `expectExact`,
`timeoutSeconds` (5600), `workspace` (`:run` or named), `capabilities.tools`
(allowlist: read write edit bash grep find ls), `session`,
`sessionForkFrom` (requires `session`). Mission fields: `objective`,
`directives[]`, optional governing `capabilities.tools`. Policy: a task may
narrow a mission's tools, never widen; empty intersection = tool-free run.
## Agent (interactive TUI)
```bash
scripts/agent.sh <name> [--mission <file>] [--workspace <ws>] [--session <s>] [--tools <list>]
```
Launches an interactive pi TUI inside the container with the four immutable
contracts + optional mission + agent identity as its system prompt,
persistent named session, optional workspace. Exit with `/quit`.
## Release
| Command | Purpose | Notes |
|---|---|---|
| `scripts/release.sh package` | Build + tag the release image | Tag: `mosaic-poc-agent:<pi>-r<release>` |
| `scripts/release.sh activate` | Health gate → atomic pointer swap | `--fault-injection` proves the refusal path |
| `scripts/release.sh rollback` | Health-gated return to previous | Refuses if image missing |
| `scripts/release.sh status` | Release, tag, active pointer, log | Safe on empty state |
## Conductor (worker patches)
```bash
scripts/conductor-apply.sh <runId> [--dry-run]
```
Auto-applies a worker's patch under `roles/conductor-policy.json`:
succeeded run → clean target tree → path allowlist → syntax gates →
apply → policy suites → attribution commit. Any failure reverts.
Push is never automatic.
## Maintenance
| Command | Purpose | Notes |
|---|---|---|
| `scripts/reset.sh` | Delete the data root | Triple-safety-checked (path, symlink, ownership marker) |
| `scripts/test-config.sh` | Config selftests (no Docker) | 24 cases |
| `scripts/test-task.sh` | Task selftests + live cases | 58 cases |
| `scripts/test-release.sh` | Release selftests | 14 cases |
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper | Token never on argv/stdout |
## Exit-code convention
`0` success · `1` operation failed · `2` invalid data/configuration ·
`3` configuration missing for a read operation · `4` usage/file/environment
problem. Scripts print diagnostics on stderr; model responses (and only
model responses) on stdout.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"missionVersion": 1, "missionVersion": 1,
"id": "m-hello", "id": "m-hello",
"objective": "Prove the startup marker path of the mosaic-poc-agent.", "objective": "Verify the startup marker path.",
"directives": [ "directives": [
"Startup verification requests are answered with the marker only.", "Startup verification requests are answered with the marker only.",
"No explanation, no formatting." "No explanation, no formatting."
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Launch an interactive (TUI) Mosaic agent in its container.
#
# Usage:
# scripts/agent.sh <name> [--mission <file>] [--workspace <ws>]
# [--session <name>] [--tools <comma,list>]
#
# The agent receives the four immutable contracts (constitution, standards,
# SOUL, USER) plus its own identity and optional mission directives as its
# system prompt, a persistent named session, and - if declared - a
# workspace and tool capabilities. The TUI opens clean; you drive.
#
# This is the Mosaic alternative to launching vanilla pi: same engine,
# governed context.
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck source=common.sh
source scripts/common.sh
NAME=""
MISSION=""
WORKSPACE=""
SESSION=""
TOOLS=""
while [ $# -gt 0 ]; do
case "$1" in
--mission) MISSION="${2:?}"; shift 2 ;;
--workspace) WORKSPACE="${2:?}"; shift 2 ;;
--session) SESSION="${2:?}"; shift 2 ;;
--tools) TOOLS="${2:?}"; shift 2 ;;
--help|-h) sed -n '2,12p' "$0"; exit 0 ;;
*) NAME="$1"; shift ;;
esac
done
[ -n "$NAME" ] || { echo "agent: usage: scripts/agent.sh <name> [--mission f] [--workspace ws] [--session s] [--tools list]" >&2; exit 4; }
case "$NAME" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid agent name" >&2; exit 4;; esac
load_config
load_release
bootstrap_runtime_dir
SESSION="${SESSION:-agent-$NAME}"
mkdir -p "$MOSAIC_DEV_DIR/sessions/$SESSION"
export MOSAIC_SESSION_DIR="/var/lib/mosaic/sessions/$SESSION"
export MOSAIC_AGENT_NAME="$NAME"
export MOSAIC_INTERACTIVE=1
export MOSAIC_TOOLS="${TOOLS:+$TOOLS}"
if [ -n "$MISSION" ]; then
[ -r "$MISSION" ] || { echo "agent: mission file not readable: $MISSION" >&2; exit 4; }
mkdir -p "$MOSAIC_DEV_DIR/agent-missions"
cp "$MISSION" "$MOSAIC_DEV_DIR/agent-missions/$NAME.json"
export MOSAIC_MISSION_FILE="/var/lib/mosaic/agent-missions/$NAME.json"
fi
if [ -n "$WORKSPACE" ]; then
case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac
mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE"
export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE"
fi
echo "agent: launching TUI agent '$NAME' (session: $SESSION, adapter: $MOSAIC_ADAPTER, model: $MOSAIC_MODEL)"
echo "agent: contracts + $([ -n "$MISSION" ] && echo 'mission' || echo 'no mission') loaded; exit the TUI with /quit"
# No -T: the TTY is the point. Ctrl+C twice or /quit exits.
exec docker compose run --rm mosaic-agent
+14
View File
@@ -0,0 +1,14 @@
---
name: ms-tools
description: Contains a reference to all available tools for Mosaic Stack.
disable-model-invocation: true
---
# ms-tools
You are a Mosaic fleet agent. A maintained toolkit lives at `<dir>`.
Use it FIRST for the tasks below — improvising with raw CLIs causes the recurring failures this
skill exists to prevent. This is the high-frequency fast path; the full reference is the
`# Machine Tools` section already in your system prompt.
+85
View File
@@ -0,0 +1,85 @@
---
name: ms-unslop
description: Cut AI tells from any writing. Must always apply.
disable-model-invocation: false
---
# Unslop
Edit text to remove AI patterns and add human voice.
## Process
1. Scan for the patterns below.
2. Rewrite. Preserve meaning, match intended tone.
3. Add soul (see next section).
4. Self-audit: "What makes this obviously AI generated?" Fix remaining tells.
## Adding soul
Removing patterns is half the job. Sterile, voiceless writing is just as obvious.
- **Have opinions.** React to facts instead of neutrally listing pros and cons.
- **Vary rhythm.** Short sentences. Then longer ones that take their time. Mix it up.
- **Acknowledge complexity.** "Impressive but also kind of unsettling" beats "impressive."
- **Use "I" when it fits.** First person isn't unprofessional.
- **Let some mess in.** Perfect structure looks machine-made.
- **Be specific.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am."
## Patterns to detect and fix
### Content
1. **Puffery.** `pivotal moment`, `testament to`, "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened.
2. **Name-dropping.** Listing media outlets without context. Pick one, say what was said.
3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources.
4. **Promotional language.** "nestled", `vibrant`, "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions.
5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete.
6. **Formulaic challenges.** "Despite challenges... continues to thrive." Replace with specific facts.
### Language
7. **AI vocabulary.** `Additionally`, `crucial`, `delve`, `enduring`, `enhance`, `fostering`, `garner`, `interplay`, `intricate`, `landscape` (abstract), `pivotal`, `showcase`, `tapestry` (abstract), `testament`, `underscore`, `vibrant`. Replace with plain words.
8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has".
9. **`Not just X, but Y`.** State the point directly instead.
10. **Rule of three.** Forcing ideas into groups of three. Use the natural number.
11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it.
12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly.
### Style
13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). Em dashes are an AI tell, and reaching for parentheses instead just trades one tell for another. If a thought needs separation, end the sentence or use a comma.
14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation.
15. **Boldface overuse.** Don't bold every proper noun or acronym.
16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert those to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine, not a tell.
17. **Title case headings.** Use sentence case.
18. **Decorative emojis.** Remove from headings and bullets.
19. **Curly quotes.** Replace with straight quotes.
### Communication artifacts
20. **Chatbot phrases.** `I hope this helps!`, `Let me know if...`, `Of course!`, `Certainly!`, `Found the smoking gun!` Remove.
21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
22. **Sycophantic tone.** `Great question!` `You're absolutely right!` Respond directly.
### Filler
23. **Filler phrases.** `In order to` becomes "To". `Due to the fact that` becomes "Because". `It is important to note that` gets deleted.
24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
25. **Generic conclusions.** "The future looks bright." State specific plans or facts.
### Jargon
26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
### Plain speech
27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
31. **Prefer the plain word.** `utilize` becomes "use", `leverage` becomes "use", `facilitate` becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
## Mention convention
A document that MENTIONS a banned word or phrase quotes it as inline code. The checker (`tools/unslop-hook/unslop-check.js`, machine source `tools/unslop-hook/lists.json`) strips code spans before matching, so a backticked mention is invisible to the gate while a bare one flags. This file follows that convention and doubles as a regression fixture: if `unslop-check.js` ever flags this file, either an edit broke the mention convention or code stripping regressed. Documents that deliberately CONTAIN slop to test detection (fixture files) are uses, not mentions; they are expected to flag.
+8
View File
@@ -33,6 +33,14 @@ for f in $FILES; do
printf '\n' >> "$TEMP" printf '\n' >> "$TEMP"
done done
# Agent identity (M13): when the launcher names the agent, the generated
# prompt states it - SOUL.md provides the persona, this provides the name.
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
printf '===== AGENT IDENTITY =====\n' >> "$TEMP"
printf 'agent name: %s\n' "$MOSAIC_AGENT_NAME" >> "$TEMP"
printf '\n' >> "$TEMP"
fi
# Sanctioned mission injection point (M4): when the task runner provides a # Sanctioned mission injection point (M4): when the task runner provides a
# mission snapshot, its objective and directives are appended AFTER the # mission snapshot, its objective and directives are appended AFTER the
# immutable contracts. Runtime data; never part of the contract fixtures. # immutable contracts. Runtime data; never part of the contract fixtures.
+15 -7
View File
@@ -1,13 +1,22 @@
#!/bin/sh #!/bin/sh
# One-shot agent dispatcher inside the container. # Agent dispatcher inside the container.
# #
# 1. Loads the contract-generated system prompt (contracts + optional # Headless (default): loads the contract-generated system prompt, then
# mission section from MOSAIC_MISSION_FILE). # dispatches one request to /opt/mosaic/adapters/<MOSAIC_ADAPTER>/adapter.sh
# 2. Dispatches to /opt/mosaic/adapters/<MOSAIC_ADAPTER>/adapter.sh per # (contract: /opt/mosaic/adapters/README.md).
# the contract in /opt/mosaic/adapters/README.md. #
# Interactive (MOSAIC_INTERACTIVE=1, from scripts/agent.sh): same prompt,
# but the adapter opens the full pi TUI with no initial prompt - the human
# drives from there.
set -eu set -eu
REQUEST="${*:-Return your startup marker and nothing else.}" REQUEST=""
if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
# Headless: args are the request; default is the startup verification
# request used by hello/verify.
REQUEST="${*:-Return your startup marker and nothing else.}"
export MOSAIC_REQUEST="$REQUEST"
fi
ADAPTER="${MOSAIC_ADAPTER:-pi}" ADAPTER="${MOSAIC_ADAPTER:-pi}"
case "$ADAPTER" in case "$ADAPTER" in
@@ -28,6 +37,5 @@ fi
/opt/mosaic/src/load-contracts.sh /opt/mosaic/contracts /var/lib/mosaic/system-prompt.md /opt/mosaic/src/load-contracts.sh /opt/mosaic/contracts /var/lib/mosaic/system-prompt.md
export MOSAIC_SYSTEM_PROMPT_FILE="/var/lib/mosaic/system-prompt.md" export MOSAIC_SYSTEM_PROMPT_FILE="/var/lib/mosaic/system-prompt.md"
export MOSAIC_REQUEST="$REQUEST"
exec "$ADAPTER_SCRIPT" exec "$ADAPTER_SCRIPT"
+83
View File
@@ -0,0 +1,83 @@
# unslop-hook
Mechanical AI-tell enforcement for pi seats. Anti-drift gate for the writing
standard in SYSTEM.md / ms-unslop: prose distribution alone decays over long
sessions; this check cannot forget.
- `lists.json`: committed machine source for every list the checker enforces:
words, phrases, punct rules, regex patterns, density thresholds. Each entry
carries provenance (`ms-unslop:<pattern id>` or `system-md`), the mention
convention, and the documented divergence of the density gate from
SYSTEM.md's outright em-dash ban. Edit lists here, not in code.
- `unslop-check.js`: dependency-free checker (node CLI + module) driven by
lists.json. Loads and schema-validates the lists on first use and hard-fails
closed: empty, unparseable, or invalid lists throw. Detects banned vocabulary,
chatbot/sycophancy phrases, filler phrases, em/en dashes, curly quotes,
`not just X but Y`. Strips fenced and inline code first, so quoted code is
never flagged. Exit 0 clean, 1 violations, 2 gate broken (lists unreadable,
never a clean verdict).
- `extension.ts`: pi extension. `message_end` checks finalized assistant text
and notifies the operator (TUI/RPC). `before_agent_start` reads the most
recent assistant reply from the session file and, if it carries tells,
injects a correction notice the model sees on its next turn. `/unslop`
reports session stats. Violation state lives in the session file, so the
injection path survives restart, resume, fork, and reload (an in-memory
pending flag was measured dead across print-mode turns, 2026-08-19). A
broken lists.json fails closed: checks stop, `broken_lists` /
`skipped_broken` events log the reason, operator notified once, seat keeps
running.
- `test-unslop-check.js`: unit tests with red and green controls.
## Use
```bash
node test-unslop-check.js # suite
node unslop-check.js <file> # CLI check
UNSLOP_LISTS=<path> node unslop-check.js <file> # alt lists location
pi -e ~/.mosaic/tools/unslop-hook/extension.ts # ad-hoc load
# deploy: copy dir to ~/.pi/agent/extensions/unslop-hook/ or seat .pi
# equivalent, or list it in settings.json "extensions"
```
Env: `MOSAIC_UNSLOP_HOOK=0` disables. `MOSAIC_UNSLOP_LOG=<path>` appends JSONL
events (loaded / flagged / notice_injected / checked / broken_lists /
skipped_broken) for headless evidence. `UNSLOP_LISTS=<path>` overrides the
lists.json location for both CLI and extension.
## Verified here (2026-08-19)
- Unit suite 24/24 (8 behavioral, 11 loader/CLI, 5 review follow-up), red and
green controls both exercised, including exit-2 on broken lists and on an
unreadable input file (S3).
- CLI: slop file exit 1, clean file exit 0, broken lists exit 2 with the fault
named on stderr.
- Extension, healthy path (print mode, zai/glm-5.3:low): startup probe loads
lists.json, reply checked clean.
- Extension, broken-lists path (print mode): `broken_lists` at startup,
`skipped_broken` per turn, seat survives, reply still delivered.
- Earlier live evidence (pre-C1, inline lists): forced-slop turn flagged;
fresh-process follow-up injected the notice and the reply came back clean;
full TUI trial (notify line, injection, /unslop stats) on session vision-unslop.
- Log evidence in session scratchpad.
## Limits
- `/unslop` command not tested headless (print mode has no command surface);
it is a thin stats wrapper.
- En dash flag fires on typographic ranges too (23); acceptable for fleet
prose, revisit if it noisifies technical writing.
- Notice injection is a nudger, not a blocker. Output already streamed to the
user stays as-is; correction lands on the next turn.
- A broken lists.json latches for the session: repairing the file mid-session
does not revive checks until the seat restarts. Acceptable for an advisory
gate (review S1).
- The fail-closed operator notification requires a UI. Print-mode sessions
log `skipped_broken` but notify nobody (review S2).
- The word/phrase lists are the mechanical subset of ms-unslop only, keyed to
pattern ids in lists.json. Style judgments (voice, rhythm, structure) stay in
the skill, not the gate.
## Promotion path
Stack issue (A4): checker shared as the single source for a matching Claude
Code Stop-hook script; lists versioned beside SYSTEM.md contract text.
+163
View File
@@ -0,0 +1,163 @@
// unslop-hook — pi extension wrapper around unslop-check.js.
// Detects mechanical AI tells in finalized assistant messages and injects a
// correction notice the model sees on its next turn. Anti-drift enforcement for
// SYSTEM.md / ms-unslop; prose distribution alone decays, this cannot forget.
//
// Deploy: copy dir to ~/.pi/agent/extensions/unslop-hook/ (or seat .pi equivalent),
// or add this file's dir to settings.json "extensions".
// Test: pi -e <abs path>/extension.ts
// Off: MOSAIC_UNSLOP_HOOK=0
// Log: MOSAIC_UNSLOP_LOG=/path/to/log.jsonl (JSONL events; headless evidence)
// Broken: lists.json missing/empty/invalid → the checker throws; checks are
// skipped, logged as skipped_broken, and the operator is notified once.
// Never silently pass while the lists cannot load (fail closed).
//
// Design note: violation state lives in the SESSION FILE, not memory. At
// before_agent_start we read the most recent assistant text message from
// ctx.sessionManager and check it there. That survives process restarts, resume,
// fork, and reload — an in-memory pending flag measured dead on 2026-08-19 when
// a print-mode second turn never injected.
import { appendFileSync } from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { checkText } from "./unslop-check.js";
interface Finding {
rule: string;
detail: string;
count: number;
}
interface MessageEntry {
type: "message";
id: string;
message: { role?: string; content?: unknown };
}
function assistantText(entry: unknown): string | null {
const e = entry as Partial<MessageEntry>;
if (e?.type !== "message") return null;
const msg = e.message;
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return null;
const text = msg.content
.filter((b): b is { type: "text"; text: string } =>
typeof b === "object" && b !== null && (b as { type?: string }).type === "text")
.map((b) => b.text ?? "")
.join("\n");
return text.trim() ? text : null; // tool-call-only assistant messages return null
}
export default function (pi: ExtensionAPI) {
if (process.env.MOSAIC_UNSLOP_HOOK === "0") return;
const LOG = process.env.MOSAIC_UNSLOP_LOG;
const log = (ev: Record<string, unknown>) => {
if (LOG) appendFileSync(LOG, JSON.stringify({ ts: Date.now(), ...ev }) + "\n");
};
// Entry ids we have already injected a notice for. In-memory only: after a
// restart the same entry may inject once more, which re-anchors the style
// after a context loss. That is wanted, not a bug.
const injectedFor = new Set<string>();
let turnsChecked = 0;
let turnsFlagged = 0;
const histogram = new Map<string, number>();
// Fail-closed path for a broken lists.json. A checker that cannot load its
// lists must never be read as "everything passed": checks stop, the skip is
// logged each turn, and the operator is notified once.
let broken: string | null = null;
let brokenNotified = false;
const reportBroken = (ctx: { hasUI?: boolean } | undefined, where: string) => {
log({ ev: "skipped_broken", where, reason: broken });
if (!brokenNotified && ctx?.hasUI) {
ctx.ui.notify(`unslop gate BROKEN: ${broken}. Fix tools/unslop-hook/lists.json; no clean verdicts until then.`, "error");
brokenNotified = true;
}
};
const safeCheck = (text: string): ReturnType<typeof checkText> | null => {
if (broken) return null;
try {
return checkText(text);
} catch (e) {
broken = String((e as Error).message);
log({ ev: "broken_lists", reason: broken });
return null;
}
};
pi.on("session_start", async (event, _ctx) => {
log({ ev: "loaded", reason: event.reason });
try {
checkText(""); // probe: load+validate lists at startup, not mid-conversation
} catch (e) {
broken = String((e as Error).message);
log({ ev: "broken_lists", reason: broken, at: "startup" });
}
});
pi.on("message_end", async (event, ctx) => {
if ((event.message as { role?: string }).role !== "assistant") return;
const text = assistantText({ type: "message", id: "", message: event.message });
if (text === null) return;
const result = safeCheck(text);
if (result === null) {
reportBroken(ctx, "message_end");
return;
}
turnsChecked++;
if (result.clean) {
log({ ev: "checked", clean: true, turn: turnsChecked, charsChecked: result.charsChecked });
return;
}
turnsFlagged++;
for (const f of result.findings) histogram.set(f.rule, (histogram.get(f.rule) ?? 0) + 1);
const summary = result.findings.map((f) => f.detail).join("; ");
if (ctx.hasUI) ctx.ui.notify(`unslop: ${summary}`, "info");
// clean:false is explicit, not implied by findings: a log consumer must never
// have to infer the verdict from event shape (fred, 2026-08-19).
log({ ev: "flagged", clean: false, turn: turnsChecked, charsChecked: result.charsChecked, findings: result.findings });
});
pi.on("before_agent_start", async (_event, ctx) => {
// Branch walks leaf -> root; first assistant entry with text is the reply
// the model is about to follow up on.
for (const entry of ctx.sessionManager.getBranch()) {
const text = assistantText(entry);
if (text === null) continue;
const id = (entry as { id?: string }).id ?? "";
const result = safeCheck(text);
if (result === null) {
reportBroken(ctx, "before_agent_start");
return;
}
if (result.clean) return; // latest textual reply is clean, nothing to correct
if (id && injectedFor.has(id)) return; // already nagged for this entry
if (id) injectedFor.add(id);
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: id, findings: result.findings });
return {
message: { customType: "unslop-notice", content, display: true },
};
}
});
pi.registerCommand("unslop", {
description: "Show unslop violation stats for this session",
handler: async (_args, ctx) => {
if (broken) {
ctx.ui.notify(`unslop gate BROKEN: ${broken}`, "error");
return;
}
const hist = [...histogram.entries()].map(([r, c]) => `${r} x${c}`).join(", ") || "none";
ctx.ui.notify(`unslop: checked ${turnsChecked}, flagged ${turnsFlagged} (${hist})`, "info");
},
});
}
+54
View File
@@ -0,0 +1,54 @@
{
"version": 1,
"convention": "Use vs mention. A document that MENTIONS a banned word or phrase quotes it as inline code (backticks). The checker strips code spans before matching, so a backticked mention is invisible to the gate while a bare one flags. A document that deliberately CONTAINS banned items to test detection (a fixture) is a use, not a mention, and is expected to flag. This file itself contains the banned items as data; that is a use.",
"punctPolicy": "Deliberate divergence from SYSTEM.md (2026-08-19): the contract forbids em dashes outright and closes the escapes. These punct rules are deliberately looser: they fire only at >= punctMinCount occurrences AND density >= punctDensityPer1k per 1000 chars. Rationale is reply-level noise, not contract strength: an advisory gate that flags every reply carrying one dash trains operators to ignore it. Measured on natural fleet prose 2026-08-19: documents run 1.7-3.2 em dashes per 1000 chars, so documents that overuse still flag. Tighten to contract strength if enforcement goes blocking or the log shows fleet prose not converging toward zero.",
"thresholds": {
"punctMinCount": 3,
"punctDensityPer1k": 1.0
},
"words": [
{ "value": "additionally", "source": "ms-unslop:7" },
{ "value": "crucial", "source": "ms-unslop:7" },
{ "value": "delve", "source": "ms-unslop:7" },
{ "value": "garner", "source": "ms-unslop:7" },
{ "value": "interplay", "source": "ms-unslop:7" },
{ "value": "intricate", "source": "ms-unslop:7" },
{ "value": "pivotal", "source": "ms-unslop:7" },
{ "value": "showcase", "source": "ms-unslop:7" },
{ "value": "tapestry", "source": "ms-unslop:7" },
{ "value": "testament", "source": "ms-unslop:7" },
{ "value": "underscore", "source": "ms-unslop:7" },
{ "value": "vibrant", "source": "ms-unslop:7" },
{ "value": "utilize", "source": "ms-unslop:31" },
{ "value": "leverage", "source": "ms-unslop:31" },
{ "value": "facilitate", "source": "ms-unslop:31" },
{ "value": "load-bearing", "source": "system-md" }
],
"phrases": [
{ "value": "worth stating plainly", "source": "system-md" },
{ "value": "here's the honest truth", "source": "system-md" },
{ "value": "heres the honest truth", "source": "system-md", "note": "apostrophe-OMITTED renderings only; ASCII and curly-apostrophe forms match the main entry because the checker normalizes U+2019/U+2018 to ASCII before phrase matching" },
{ "value": "the real tension", "source": "system-md" },
{ "value": "carry the argument", "source": "system-md" },
{ "value": "in order to", "source": "ms-unslop:23" },
{ "value": "due to the fact that", "source": "ms-unslop:23" },
{ "value": "it is important to note", "source": "ms-unslop:23" },
{ "value": "i hope this helps", "source": "ms-unslop:20" },
{ "value": "let me know if", "source": "ms-unslop:20" },
{ "value": "of course!", "source": "ms-unslop:20" },
{ "value": "certainly!", "source": "ms-unslop:20" },
{ "value": "found the smoking gun", "source": "ms-unslop:20" },
{ "value": "happy to help", "source": "ms-unslop:20", "note": "extension of the named pattern set" },
{ "value": "great question", "source": "ms-unslop:22" },
{ "value": "absolutely right", "source": "ms-unslop:22" },
{ "value": "excellent question", "source": "ms-unslop:22", "note": "extension of the named pattern set" }
],
"punct": [
{ "value": "em", "label": "em dash", "chars": ["\u2014"], "source": "ms-unslop:13+system-md" },
{ "value": "en", "label": "en dash", "chars": ["\u2013"], "source": "ms-unslop:13" },
{ "value": "curly", "label": "curly quote/apostrophe", "chars": ["\u201c", "\u201d", "\u2018", "\u2019"], "source": "ms-unslop:19" }
],
"patterns": [
{ "value": "not-just-but", "regex": "not just\\s+[^.!?]{0,80}?\\s+but", "flags": "gi", "detail": "not just X but Y", "source": "ms-unslop:9" }
]
}
+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);
+181
View File
@@ -0,0 +1,181 @@
#!/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);
}