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.

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.
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.
| Mod | What it does |
|---|---|
decision-model | The 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-effort | Auto 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-stop | Unexpected 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-rules | omp'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-methods | Two 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-kernel | One 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. |
Built and tested on Claude Code 2.1.288. The mods use the in-process $ hook API, so older versions won't load 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.
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).
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)..claude/ttsr/ (project) or ~/.claude/ttsr/; existing omp rule folders are read too. See e2e/ttsr.sh for one of each kind.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).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.$.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.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.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.Each item was observed in a run, not read from docs.
userConfig rows are in $.config.list() in an interactive session but not under claude -p, where only the engine's 40 rows come back.$.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.command.run hook may not call $.session.compact; the engine refuses because the command holds the turn. Hence /compact shake, not /shake.$.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.dependencies aren't loaded doesn't load at all. A marketplace install pulls the dependency in (+ 1 dependency).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.CLAUDE_CODE_CHILD_SESSION and stops saving transcripts; the e2e scripts unset it.claude plugin test):session.append never sees $.session.append.agent.spawn gets no agent id.Those paths are covered by e2e instead.
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./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.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 reset (or reset: true). agent()'s schema is parsed, not validated: a wrong shape reaches the cell as is.hooks/register.ts 101 lines1import 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};
101hooks/rules.ts 200 lines1// 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