SLOPSHOPPER

ttsr-rules

oh-my-pi TTSR rules: a regex rule denies the tool call that would break it, with the rule as the reason; a question rule is judged after each turn.

newguardcommandtoast
v0.1.0NOASSERTIONupdated 2026-10-03kzarzycki/claude-mods/plugins/ttsr-rules
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ttsr-rules
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ttsr ⎿ ttsr-rules: no TTSR rules in /Users/dev/.omp/agent/rules, /work/app/.omp/rules, /Users/dev/.claude/ttsr, /work/app/.claude ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods: plugins whose hooks run inside Claude Code's engine through the in-process $ API. Each one installs on its own from this repository's plugin marketplace. Mods that make decisions with a model (auto-effort, resume-on-stop, ttsr-rules) depend on decision-model, which provides that model.

oh-my-pi features

The first set rebuilds features of oh-my-pi (omp), a coding-agent harness, as mods. The research behind it rates every omp feature by how naturally it fits Claude Code: proposal (written before the build, where the project was called omc). The rest of this README covers that set: how to install it, how it works, and what building it found out about the platform.

ModWhat it does
decision-modelThe decision model, for other mods: $.decision.ask({ state, questions }), with typed questions (choice, noul = probability of yes, score) in the request and answer shape of TypeSafe's System One API. Any endpoint that speaks that protocol is a backend (presets openrouter and typesafe, or a URL; Jev is the default model). Without a key, a small Claude model answers the same questions as text. It does no deciding of its own. /decision shows the backend and every recent decision with the mod that asked; /decision ask <question> -- <state> tries one.
auto-effortAuto effort: asks the decision model how open-ended each prompt is and runs that turn at the chosen effort, up to a ceiling. /effort-rate <prompt>.
resume-on-stopUnexpected stops: a turn that ends on "Let me run the tests next." without acting is spotted by the decision model and gets one resume turn.
ttsr-rulesomp's TTSR rule files (condition, scope, globs, question) from ~/.omp/agent/rules, .omp/rules, ~/.claude/ttsr, .claude/ttsr. A condition rule denies the tool call that would break it and hands the model the rule as the reason. A question rule is put to the decision model after each turn; a yes leaves the rule for the model's next turn. /ttsr, /ttsr reload.
compact-methodsTwo of omp's compaction methods. /compact shake moves old tool output to files under ~/.claude/compact-methods/<session>/ and leaves a pointer the model can Read (no model call). /compact handoff [focus] replaces the context with a handoff document written by a fork of the main thread. autoMethod picks what runs when the context fills.
agent-hub/hub pane: this session's subagents with status, model, turns, tokens and their latest answer, plus a box that sends a message to a running one. /hub list, /hub send <id> <message>.
eval-kernelOne eval tool backed by a long-lived Bun kernel. State persists between cells (top-level declarations, imports). Cells call Claude Code tools (await tool.Read({ file_path })), models (completion()), subagents (agent(), with a JSON Schema schema for a parsed answer) and the decision model (judge(), when decision-model is loaded), in parallel with Promise.all. A subagent's completion notice goes to the cell, not the conversation. Interrupting a cell resets the kernel. Every call goes back through Claude Code, so permissions and other mods' hooks still apply. /eval <code>, /eval vars, /eval reset.

Install

Built and tested on Claude Code 2.1.288. The mods use the in-process $ hook API, so older versions won't load them.

From GitHub (to use them)

claude plugin marketplace add kzarzycki/claude-mods
claude plugin install auto-effort@claude-mods     # pulls in decision-model
claude plugin install resume-on-stop@claude-mods
claude plugin install ttsr-rules@claude-mods
claude plugin install compact-methods@claude-mods
claude plugin install agent-hub@claude-mods
claude plugin install eval-kernel@claude-mods

Pick any subset; each mod works alone, and the three that make decisions pull in decision-model. Inside a session, /plugin does the same from a menu. claude plugin marketplace update claude-mods fetches new versions.

From a clone (to change them)

git clone https://github.com/kzarzycki/claude-mods && cd claude-mods
claude --plugin-dir plugins/decision-model --plugin-dir plugins/auto-effort   # one session

To load them in every session, including ones another tool starts (omnigent, an IDE), list the folders in ~/.claude/settings.json; an interactive session reloads a mod when you save its files:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/plugins/decision-model:/path/to/claude-mods/plugins/auto-effort" } }

Options of mods loaded this way are under <name>@inline (in /config, or claude plugin configure decision-model@inline).

Setup

  • Jev for decisions. Without a key, decision-model asks Claude Haiku. To use Jev, give it an OpenRouter key without echoing it: ``sh read -rs K && printf '{"apiKey":"%s"}' "$K" | claude plugin configure decision-model@claude-mods --values-stdin; unset K ` (decision-model@inline for a clone), or set OPENROUTER_API_KEY. A TypeSafe key needs "endpoint":"typesafe" too; endpoint also takes the URL of any other System One server, with model naming its model. /decision` shows which backend answers.
  • eval-kernel needs Bun on the PATH (or its bun option set to one).
  • TTSR rules are Markdown files in .claude/ttsr/ (project) or ~/.claude/ttsr/; existing omp rule folders are read too. See e2e/ttsr.sh for one of each kind.

Checks

  • scripts/check.sh: validate, unit-test (claude plugin test) and type-check every mod, plus the kernel's self-check. No model calls, under ten seconds.
  • e2e/run.sh [decision eval ttsr compact hub]: real claude sessions with the mods loaded, asserting on output and on files the mods write. They make real model calls; the whole run takes about four minutes. hub and the question-rule check drive an interactive session through expect, because headless claude -p differs there (see below).

How the pieces work

  • A decision model, and the places that use it. decision-model owns the protocol and the backend; each place a decision is made (effort, stops, question rules) is its own mod that asks through $.decision.ask and names its purpose for the log. Swapping Jev for another model, or for Claude, changes no consumer.
  • $.decision across mods. A mod adds a noun to $ in engine.create. The noun's methods are only placeholders: calling $.decision.ask(x) raises the event decision.ask, which decision-model's hook answers with a $ of its own. A $ captured at engine.create can't be used later; the validator refuses it.
  • Eval kernel transport. A mod can write to a child's stdin only once, so the kernel serves HTTP on a Unix socket instead ($.http.fetch with socketPath). A cell that needs the host parks the call and returns it as a call event; the mod runs it and answers with /resume.
  • Subagents from a cell. agent() spawns the subagent and the eval hook waits on /next. The subagent's hand-back (SubagentHandback, or its final turn.complete) answers the call through /answer. Every wait happens inside $.http.fetch, which doesn't count against the hook's 10-second budget. Waiting on an ordinary promise would count.
  • Kernel lifetime. The kernel is started from session.start and restarted when it exits (/eval reset simply exits it). It dies with the mod's module, and a parent-pid watchdog ends it if Claude Code dies.

What the spike found about the platform

Each item was observed in a run, not read from docs.

  • Settings rows are interactive-only. Plugin userConfig rows are in $.config.list() in an interactive session but not under claude -p, where only the engine's 40 rows come back.
  • Notes appended after the last turn are lost headless. A $.session.append made after the last turn of a claude -p run never reaches the transcript, because the process exits. Interactively the model reads it on its next turn.
  • Compaction can't start from a command. A command.run hook may not call $.session.compact; the engine refuses because the command holds the turn. Hence /compact shake, not /shake.
  • Fork can fail after a resume. $.model.fork has nothing to fork right after a session is resumed headless. Handoff falls back to $.model.complete over the messages session.compact passes in.
  • A missing dependency blocks the load. A mod whose dependencies aren't loaded doesn't load at all. A marketplace install pulls the dependency in (+ 1 dependency).
  • Jev returns real distributions; the Claude backend can't. Jev's choice answers carry probabilities (xhigh 0.99, high 0.01, confidence 0.98); the Claude text judge answers one-hot. Jev's noul can sit near the middle where a reader would say yes: "Is DROP TABLE users; in production irreversible?" came back 0.51, so thresholds need tuning per question.
  • Child sessions don't save transcripts. A session started from inside another Claude Code session inherits CLAUDE_CODE_CHILD_SESSION and stops saving transcripts; the e2e scripts unset it.
  • Haiku subagents can miss the task. A Haiku subagent sometimes answers its injected system context ("System initialization acknowledged…") instead of the task. The e2e checks use Sonnet for subagents.
  • Test-kit gaps (claude plugin test):
  • A test hook on session.append never sees $.session.append.
  • A test hook answering agent.spawn gets no agent id.
  • Test plugins run without their closures.

Those paths are covered by e2e instead.

Not done or not verified

  • Other System One servers. Jev through the OpenRouter preset is checked live (the decision and ttsr e2e suites pass against it, answering as typesafe/jev-1.13-20260917). The TypeSafe preset and custom URLs are checked only against faked replies.
  • Steering a running subagent (/hub send, the pane's input). No automated check: the kit can't intercept $.session.append, and an e2e needs a subagent that stays running long enough.
  • TTSR rules scoped to edit/write miss Bash. A model that's refused a Write can write the same file with printf … > file (seen in an e2e run). Scope a rule to tool:bash too if that matters; matching shell redirections to file globs isn't done.
  • Eval cell timeouts. A cell has no timeout. A synchronous infinite loop blocks the kernel until /eval reset (or reset: true). agent()'s schema is parsed, not validated: a wrong shape reaches the cell as is.
  • omp features left out of this MVP. Snapcompact, omp's soft compaction, idle compaction, TTSR rules on streamed text (Claude Code can't take back text it has already shown), and multi-vendor models.
Source 2 files
hooks/register.ts 101 lines
1import type { EngineInterface, Register } from "claude-code";
2import { matchToolRule, parseRule, type Rule } from "./rules";
3
4// ttsr-rules: oh-my-pi's time-traveling stream rules, at the seams Claude Code offers.
5// - A rule with `condition:` is checked against what each tool call would write or run; a match
6//   denies the call and hands the model the rule's body as the reason (omp aborts the stream
7//   and retries; a denied call is the same lesson one step later).
8// - A rule with `question:` is a yes/no question the decision model ($.decision from decision-model) answers about each
9//   finished main-thread turn; a yes leaves the rule for the model's next turn.
10
11const QUESTION_THRESHOLD = 0.5;
12
13let rules: Rule[] = [];
14let cwd = "";
15const hits = new Map<string, number>();
16
17/** Rule folders, lowest precedence first; a later file with the same name replaces an earlier one. */
18async function ruleDirs($: EngineInterface, root: string): Promise<string[]> {
19  const home = (await $.env.get("HOME")) ?? "";
20  return [`${home}/.omp/agent/rules`, `${root}/.omp/rules`, `${home}/.claude/ttsr`, `${root}/.claude/ttsr`];
21}
22
23async function loadRules($: EngineInterface, root: string): Promise<Rule[]> {
24  const byName = new Map<string, Rule>();
25  for (const dir of await ruleDirs($, root)) {
26    if (!(await $.fs.exists(dir))) continue;
27    for (const entry of await $.fs.list(dir)) {
28      if (entry.kind !== "file" || !entry.name.endsWith(".md")) continue;
29      const path = `${dir}/${entry.name}`;
30      const rule = parseRule(entry.name.replace(/\.md$/, ""), path, await $.fs.read(path));
31      if (rule) byName.set(rule.name, rule);
32    }
33  }
34  return [...byName.values()];
35}
36
37const tag = (rule: Rule) => `<ttsr-rule name="${rule.name}">\n${rule.body}\n</ttsr-rule>`;
38
39export const register: Register = (on, options) => {
40  const questionRules = options.questionRules !== false;
41  const resume = options.questionAction === "resume";
42
43  on("session.start", async ($, e, next) => {
44    cwd = e.cwd;
45    rules = await loadRules($, cwd);
46    await $.command.register({ name: "ttsr", description: "ttsr-rules: list loaded rules and their hits; `/ttsr reload` rereads the rule folders", argumentHint: "[reload]" });
47    return next(e);
48  });
49
50  on("tool.call", async ($, e, next) => {
51    const hit = matchToolRule(rules, e.tool, e as unknown as Record<string, unknown>, cwd);
52    if (!hit) return next(e);
53    hits.set(hit.rule.name, (hits.get(hit.rule.name) ?? 0) + 1);
54    $.ui.toast(`ttsr: ${hit.rule.name} blocked ${e.tool}`);
55    return { deny: `TTSR rule "${hit.rule.name}" stopped this ${e.tool} call: it matched \`${hit.match.slice(0, 200)}\`. Follow the rule and try again.\n\n${tag(hit.rule)}` };
56  });
57
58  on("turn.complete", async ($, e, next) => {
59    const done = await next(e);
60    const asked = questionRules && !e.agentId && e.reason === "answer" && e.answer.trim() ? rules.filter(r => r.enabled && r.question) : [];
61    if (asked.length === 0) return done;
62    try {
63      const ids = asked.map((r, i) => `r${i}`);
64      const r = await $.decision.ask({
65        purpose: "ttsr",
66        state: { assistant_message: e.answer.slice(-4000) },
67        questions: Object.fromEntries(asked.map((rule, i) => [ids[i]!, { type: "noul" as const, instructions: rule.question! }])),
68      });
69      const fired = asked.filter((_, i) => {
70        const a = r.answers[ids[i]!];
71        return a?.type === "noul" && a.noul >= QUESTION_THRESHOLD;
72      });
73      if (fired.length === 0) return done;
74      for (const rule of fired) hits.set(rule.name, (hits.get(rule.name) ?? 0) + 1);
75      const text = `The last answer broke ${fired.length === 1 ? "this rule" : "these rules"}. Apply ${fired.length === 1 ? "it" : "them"} from now on.\n\n${fired.map(tag).join("\n\n")}`;
76      $.ui.toast(`ttsr: ${fired.map(f => f.name).join(", ")}`);
77      if (resume) void $.prompt.submit({ text }).catch(() => {});
78      else {
79        const appended = await $.session.append({ message: { type: "user", content: [{ type: "text", text }] } });
80        if (appended.deny !== undefined) $.ui.log(`ttsr-rules: rule note refused: ${appended.deny}`, { to: "debug" });
81      }
82    } catch (err) {
83      $.ui.log(`ttsr-rules: question rules skipped: ${String(err)}`, { to: "debug" });
84    }
85    return done;
86  });
87
88  on("command.run", { command: "ttsr" }, async ($, e) => {
89    if (e.args.trim() === "reload") rules = await loadRules($, cwd);
90    if (rules.length === 0) return { text: `no TTSR rules in ${(await ruleDirs($, cwd)).join(", ")}` };
91    return {
92      text: rules
93        .map(r => {
94          const kind = [r.condition && `condition /${r.condition.source}/${r.tools.length ? ` on ${r.tools.join(",")}` : ""}`, r.question && `question "${r.question}"`].filter(Boolean).join("; ");
95          return `${r.enabled ? "●" : "○"} ${r.name} (${hits.get(r.name) ?? 0} hits): ${kind}\n    ${r.source}`;
96        })
97        .join("\n"),
98    };
99  });
100};
101
hooks/rules.ts 200 lines
1// TTSR rule files: Markdown with a frontmatter block, the same format oh-my-pi reads.
2//
3//   ---
4//   description: No `any` in TypeScript
5//   condition: ":\s*any\b"
6//   scope: tool:edit(*.ts)
7//   globs: ["src/**"]
8//   ---
9//   Use `unknown` and narrow it instead of `any`.
10//
11// `condition` is a regex checked against what a tool is about to write or run.
12// `question` is a yes/no question the judge answers about each finished turn.
13// A rule with neither is not a TTSR rule and is skipped.
14
15export type Rule = {
16  name: string;
17  source: string;
18  description: string;
19  body: string;
20  condition?: RegExp;
21  question?: string;
22  /** Tool names the condition applies to; empty means every tool. */
23  tools: string[];
24  /** Path globs from the scope, e.g. `tool:edit(*.ts)`; the file must match one when present. */
25  scopeGlobs: RegExp[];
26  /** Path globs from `globs:`; the file must also match one of these when present. */
27  globs: RegExp[];
28  enabled: boolean;
29};
30
31// ponytail: a flat YAML subset (key: value, inline [a, b] lists, "- item" lists, quoted strings).
32// Enough for rule frontmatter; nested maps are ignored. Upgrade path: bundle a YAML parser.
33export function parseFrontmatter(text: string): { data: Record<string, unknown>; body: string } {
34  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
35  if (!m) return { data: {}, body: text.trim() };
36  const data: Record<string, unknown> = {};
37  let listKey: string | undefined;
38  for (const raw of m[1]!.split(/\r?\n/)) {
39    const item = /^\s+-\s+(.*)$/.exec(raw);
40    if (item && listKey) {
41      (data[listKey] as unknown[]).push(scalar(item[1]!));
42      continue;
43    }
44    const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(raw);
45    if (!kv) continue;
46    const key = kv[1]!;
47    const value = kv[2]!;
48    if (value === "") {
49      data[key] = [];
50      listKey = key;
51    } else {
52      listKey = undefined;
53      data[key] = /^\[.*\]$/.test(value) ? splitList(value.slice(1, -1)).map(scalar) : scalar(value);
54    }
55  }
56  return { data, body: m[2]!.trim() };
57}
58
59function splitList(s: string): string[] {
60  const out: string[] = [];
61  let cur = "";
62  let quote = "";
63  for (const ch of s) {
64    if (quote) {
65      cur += ch;
66      if (ch === quote) quote = "";
67    } else if (ch === '"' || ch === "'") {
68      quote = ch;
69      cur += ch;
70    } else if (ch === ",") {
71      out.push(cur.trim());
72      cur = "";
73    } else cur += ch;
74  }
75  if (cur.trim()) out.push(cur.trim());
76  return out;
77}
78
79function scalar(v: string): string | boolean | number {
80  const s = v.trim();
81  if (/^".*"$/.test(s)) return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
82  if (/^'.*'$/.test(s)) return s.slice(1, -1).replace(/''/g, "'");
83  if (s === "true") return true;
84  if (s === "false") return false;
85  if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
86  return s;
87}
88
89/** Glob to regex: `**` crosses directories, `*` and `?` don't, `{a,b}` alternates. */
90export function globToRegExp(glob: string): RegExp {
91  let re = "";
92  for (let i = 0; i < glob.length; i++) {
93    const c = glob[i]!;
94    if (c === "*" && glob[i + 1] === "*") {
95      re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*";
96      i += glob[i + 2] === "/" ? 2 : 1;
97    } else if (c === "*") re += "[^/]*";
98    else if (c === "?") re += "[^/]";
99    else if (c === "{") {
100      const end = glob.indexOf("}", i);
101      re += `(?:${glob.slice(i + 1, end).split(",").map(escape).join("|")})`;
102      i = end;
103    } else re += escape(c);
104  }
105  // A glob without a slash matches the file name anywhere, like .gitignore.
106  return new RegExp(glob.includes("/") ? `^${re}$` : `(?:^|/)${re}$`);
107}
108const escape = (s: string) => s.replace(/[.+^$()|[\]\\]/g, "\\$&");
109
110// omp names tools in lower case; map them to Claude Code's.
111const TOOL_ALIASES: Record<string, string[]> = {
112  edit: ["Edit", "MultiEdit", "NotebookEdit"],
113  write: ["Write"],
114  bash: ["Bash"],
115  read: ["Read"],
116};
117
118/** `tool`, `tool:edit`, `tool:edit(*.ts)`, `tool:edit,write(src/**)`; `text`/`thinking` aren't checkable here. */
119function parseScope(scope: unknown): { tools: string[]; globs: string[]; streamOnly: boolean } {
120  const parts = (Array.isArray(scope) ? scope : scope ? [scope] : []).map(String);
121  if (parts.length === 0) return { tools: [], globs: [], streamOnly: false };
122  const tools: string[] = [];
123  const globs: string[] = [];
124  let toolScoped = false;
125  for (const p of parts) {
126    const m = /^tool(?::([^()]+))?(?:\(([^)]*)\))?$/.exec(p.trim());
127    if (!m) continue;
128    toolScoped = true;
129    for (const t of (m[1] ?? "").split(",").map(s => s.trim()).filter(Boolean)) tools.push(...(TOOL_ALIASES[t.toLowerCase()] ?? [t]));
130    if (m[2]) globs.push(...m[2].split(",").map(s => s.trim()));
131  }
132  return { tools, globs, streamOnly: !toolScoped };
133}
134
135export function parseRule(name: string, source: string, text: string): Rule | undefined {
136  const { data, body } = parseFrontmatter(text);
137  const cond = data.condition ?? data.ttsr_trigger;
138  const question = typeof data.question === "string" ? data.question : undefined;
139  if (cond === undefined && !question) return undefined;
140  const scope = parseScope(data.scope);
141  // A condition scoped only to streamed text/thinking can't be checked from tool calls; keep the rule
142  // for its question (if any) and drop the condition.
143  let condition: RegExp | undefined;
144  if (cond !== undefined && !scope.streamOnly) {
145    try {
146      condition = new RegExp(String(cond), "m");
147    } catch {
148      condition = undefined;
149    }
150  }
151  if (!condition && !question) return undefined;
152  const globList = Array.isArray(data.globs) ? data.globs.map(String) : typeof data.globs === "string" ? [data.globs] : [];
153  return {
154    name,
155    source,
156    description: String(data.description ?? ""),
157    body,
158    condition,
159    question,
160    tools: scope.tools,
161    scopeGlobs: scope.globs.map(globToRegExp),
162    globs: globList.map(globToRegExp),
163    enabled: data.enabled !== false,
164  };
165}
166
167/** The text a tool is about to write or run, which conditions are checked against. */
168export function toolPayload(tool: string, input: Record<string, unknown>): { text: string; path?: string } {
169  const path = typeof input.file_path === "string" ? input.file_path : typeof input.notebook_path === "string" ? input.notebook_path : undefined;
170  switch (tool) {
171    case "Write":
172      return { text: String(input.content ?? ""), path };
173    case "Edit":
174      return { text: String(input.new_string ?? ""), path };
175    case "MultiEdit":
176      return { text: ((input.edits as { new_string?: string }[]) ?? []).map(e => e.new_string ?? "").join("\n"), path };
177    case "NotebookEdit":
178      return { text: String(input.new_source ?? ""), path };
179    case "Bash":
180      return { text: String(input.command ?? "") };
181    default:
182      return { text: JSON.stringify(input), path };
183  }
184}
185
186/** The first enabled rule whose condition matches this tool call. */
187export function matchToolRule(rules: Rule[], tool: string, input: Record<string, unknown>, cwd = ""): { rule: Rule; match: string } | undefined {
188  const { text, path } = toolPayload(tool, input);
189  const rel = path && cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path;
190  for (const rule of rules) {
191    if (!rule.enabled || !rule.condition) continue;
192    if (rule.tools.length && !rule.tools.includes(tool)) continue;
193    if (rule.scopeGlobs.length && (!rel || !rule.scopeGlobs.some(g => g.test(rel)))) continue;
194    if (rule.globs.length && (!rel || !rule.globs.some(g => g.test(rel)))) continue;
195    const m = rule.condition.exec(text);
196    if (m) return { rule, match: m[0] };
197  }
198  return undefined;
199}
200