SLOPSHOPPER

effort-toggle

Effort toggle experiment.

newguardtool
★ 6v0.1.0no licenseupdated 2026-10-17mareurs/claude-plugins/effort-steering/spikes/toggle/mod
A shopper browsing a rack in a slop shop
README

Claude Plugins

An opinionated collection of Claude Code plugins, primarily developed for internal team use. Public so colleagues and collaborators can install directly.

These plugins reflect specific workflows and tool choices -- they may not suit every setup. Feel free to fork and adapt.

Quick Start

/plugin marketplace add mareurs/claude-plugins
/plugin install sdd@claude-plugins
/plugin install codescout-companion@claude-plugins
/plugin install buddy@claude-plugins

Available Plugins

PluginVersionDescription
sdd2.4.2Specification-Driven Development: governance, workflow commands, and enforcement hooks
codescout-companion1.20.20Companion plugin for codescout MCP server: injects tool guidance, redirects Read/Grep/Glob/Edit/Write to symbol-aware tools, auto-reindex + drift warnings, worktree shared-asset symlinking
statusline1.2.1Rich, color-coded terminal status line: model, context %, rate limits (incl. per-model weekly), git info, duration. Self-heals orphan statusLine settings when sibling plugins are uninstalled.
buddy0.11.13Himalayan-aesthetic bodhisattva companion: 12 specialist masters on demand, AI judge, focus tracking, statusline integration
Pi harness0.1.0Companion for pi: Codescout routing, skill-load tracker, recon badge, MCP status widget, and local read-only session snapshots
session-bridge0.1.0Cross-session MCP bridge: ask one Claude Code session a question from another, answered in its loaded context. Rust MCP server, bash SessionStart/Stop hooks.
legible0.2.0Output style legible:Explanatory STE: the Explanatory style (★ Insight blocks) plus about 80% of the ASD-STE100 writing rules, so prose is easier to read and check.

Requirements

Per-plugin requirements

PluginAdditional Requirements
sddNone (core workflow). Serena MCP for /drift and /document.
codescout-companioncodescout MCP server configured in .mcp.json or globally.

Pi

For pi users, this repo ships a harness adapter in harnesses/pi/. The harnesses/ directory is the home for non-Claude-Code integrations; future Codex, GitHub Copilot, and other harness adapters belong alongside Pi there.

Full setup involves three components that layer on top of each other:

  1. pi-mcp-adapter (pi package) — connects MCP servers to pi
  2. claude-plugins Pi harness — widget, session bridge, guards, and skill dirs wired into pi
  3. codescout-companion Pi adapter — routing extension + canonical skills, packaged with this repository

Quick install (claude-plugins part):

pi install npm:pi-mcp-adapter          # once — adds pi-mcp-adapter package
git clone https://github.com/mareurs/claude-plugins
cd claude-plugins/harnesses/pi
./install.sh

Then configure ~/.pi/agent/mcp.json with your codescout binary path and any API keys, and run /mcp reconnect codescout once in pi to warm the directTools cache.

What you get:

  • Widget below the editor: cs: reconnaissance [recon F2/W1], skills: debugging-yeti, pdf …, MCP: 2/2 codescout ● researcher ●
  • All skill directories (codescout-companion/skills, buddy/skills, sdd/skills) wired into pi — no duplication
  • codescout tools (symbols, read_file, edit_code, …) available as first-class pi tools (not proxied)
  • Research skills (/skill:research-web, /skill:research-subagent) ready with researcher-mcp

See harnesses/pi/README.md for the full step-by-step install, mcp.json examples, extension details, and skill reference.

Routing pi through a local LLM proxy

pi supports an Anthropic-Messages-compatible proxy for any provider via ~/.pi/agent/models.json. Two patterns worth knowing:

Single upstream (Anthropic direct):

{
  "providers": {
    "anthropic": { "baseUrl": "http://localhost:8082" }
  }
}

Multiple upstreams through one proxy (Anthropic + GitHub Copilot): if your proxy has a fixed upstream, use a per-request X-Proxy-Upstream header so the proxy can route to the right backend. The header is stripped before forwarding. GitHub Copilot models already speak the Anthropic Messages API, so the same proxy serves both.

{
  "providers": {
    "anthropic": { "baseUrl": "http://localhost:8082" },
    "github-copilot": {
      "baseUrl": "http://localhost:8082",
      "headers": { "X-Proxy-Upstream": "https://api.individual.githubcopilot.com" }
    }
  }
}

See docs/pi-integration.md for the full setup: proxy implementation, local model providers, skill versioning, and the X-Proxy-Upstream contract.

Plugins

SDD (Specification-Driven Development)

A methodology where code follows specifications. Every feature starts with a clear definition of what before diving into how.

Commands: /specify, /plan, /review, /drift, /document, /bootstrap-docs, /sdd-init

Skills: sdd-flow (full lifecycle orchestration)

Hooks: spec-guard, review-guard, subagent-inject, session-start

See sdd/README.md for full documentation.

codescout-companion

Companion plugin for codescout MCP server. Routes Claude to use codescout's symbol-aware tools instead of Read/Grep/Glob. Auto-detects codescout from .mcp.json, ~/.claude/.claude.json, ~/.claude/settings.json, or ~/.claude.json.

Hooks:

  • session-start -- Tool guidance, memory hints, onboarding nudge, auto-reindex + drift warnings
  • subagent-guidance -- Injects codescout guidance into all subagents (MCP server_instructions only reach the main agent)
  • post-tool-guidance -- PostToolUse soft warnings when Read/Grep/Glob are used on source files, suggests codescout alternatives
  • worktree-activate -- PostToolUse: creates write-guard marker + injects activate_project guidance after EnterWorktree
  • worktree-write-guard -- PreToolUse: hard-blocks codescout write tools in worktrees until activate_project is called
  • ce-activate-project -- PostToolUse: clears write-guard marker after activate_project fires

See codescout-companion/README.md for details and configuration.

Pi harness

The Pi harness integrates Codescout as Pi's primary code-intelligence layer. It owns three TypeScript extensions, the local session bridge, and an AGENTS.md routing guide.

Extensions:

  • codescout-companion.ts (harnesses/pi/) — widget below editor: skill-load tracker, recon badge, MCP server status
  • secret-guard.ts (harnesses/pi/) — fail-closed shell-egress guard
  • session-bridge.ts (harnesses/pi/) — local, read-only Pi session snapshots
  • codescout-mode.ts (codescout-companion/.pi/) — packaged routing adapter that routes Pi native read/edit/write and redundant source shell commands to Codescout when replacement tools are available

Skills wired into pi: reconnaissance, explore-project, 12 buddy specialists, sdd-flow, researcher-mcp, research-web, research-subagent, tracker-hygiene

MCP: codescout tools surface as first-class pi tools via directTools (no mcp__codescout__ prefix). researcher-mcp optional.

See harnesses/pi/README.md for full install instructions.

Team Setup

Add to your project's .claude/settings.json so all team members get the plugins automatically:

{
  "extraKnownMarketplaces": {
    "claude-plugins": {
      "source": {
        "source": "github",
        "repo": "mareurs/claude-plugins"
      }
    }
  },
  "enabledPlugins": {
    "sdd@claude-plugins": true,
    "codescout-companion@claude-plugins": true,
    "buddy@claude-plugins": true
  }
}

When team members trust the repository folder, Claude Code automatically installs the marketplace and plugins.

Contributing

Working on this repo? Install the local git hooks once per clone:

./scripts/install-hooks.sh

They are not synced by git (nothing is, in .git/hooks/), so a fresh clone has none. The pre-push shim carries force-push protection on main and a version-bump parity check that stops a hand-bumped version reaching main while the installed profiles still disagree about it. ./tests/run-all.sh warns when they are absent.

License

MIT

Source 1 files
hooks/register.ts 233 lines
1import type { Register } from 'claude-code'
2
3// effort-toggle: the mod of the effort-toggle experiment (spec 2026-10-17-effort-toggle-design.md, "The mod").
4//
5// One config file, `${$.plugin.root}/config.json`, written by `writeHookCopy` once per batch (not by the stage step),
6// picks the mode:
7//   toggle    registers the set_effort tool; the model's last valid choice is the effort of every later main step.
8//   placebo   registers the same tool and answers the same text, but rewrites no step (the tool does nothing).
9//   random    registers the same tool and answers the same text; the effort of the main steps follows a seeded
10//             two-state chain, independent of what the model asks (randomLevels below is that chain).
11//   schedule  registers no tool; request number n asks schedule[min(n, length - 1)].
12// Levels are exactly 'low' and 'xhigh'. A "request" is a request of the MAIN loop, counted across the whole session
13// from 0 (turn.step's `index` is per turn and is not used). Subagent steps (agentId set) never advance the count and
14// get the current main level, which is none until the first main step has chosen one.
15//
16// Log: one JSON line per event at config.logPath, the whole file rewritten after each line because $.fs.write has no
17// append. A log failure never breaks a request or a tool answer. A missing or malformed config registers nothing,
18// rewrites nothing and leaves one `error` line at FALLBACK_LOG.
19//   { type: 'step', request: number | null, index, agentId: string | null, turnId, before, after }   index = the engine's
20//       per-turn step index (it restarts every turn; `request` does not); before = the effort the step carried, after =
21//       the effort the request carries (equal to before when not rewritten); null only if it had none
22//   { type: 'call', request, agentId: string | null, level, reason, text }    text = the result text the tool answered
23//   { type: 'invalid', request, agentId: string | null, input }    input without the reserved keys (tool, tool_use_id,
24//       agentId, consent)
25//   { type: 'error', message }
26//
27// This file is loaded by the engine AND imported by Node in mod.test.mjs, so it is erasable TypeScript only (type
28// annotations and `import type`: no enums, namespaces or parameter properties). State lives in `register`'s closure.
29
30export const TOOL_NAME = 'set_effort'
31export const FULL_TOOL_NAME = 'mcp__effort-toggle__set_effort'
32export const FALLBACK_LOG = '/run/state/effort-toggle/log.jsonl'
33export const TOOL_DESCRIPTION =
34  'You start at low reasoning effort. Call this tool to change your effort for your next steps. Reviewing and verifying your work need higher effort: switch to xhigh before you review a change or run your final checks. When you judge that low effort is enough for what comes next, switch back to low.'
35export const INPUT_SCHEMA = {
36  type: 'object',
37  properties: { level: { type: 'string', enum: ['low', 'xhigh'] }, reason: { type: 'string' } },
38  required: ['level', 'reason'],
39}
40const DENY_TEXT = 'set_effort needs level "low" or "xhigh" and a non-empty reason.'
41
42type Level = 'low' | 'xhigh'
43type RandomParams = { pUp: number; pDown: number; seed: number }
44type ModConfig =
45  | { mode: 'toggle' | 'placebo'; logPath: string }
46  | { mode: 'schedule'; schedule: Level[]; logPath: string }
47  | { mode: 'random'; random: RandomParams; logPath: string }
48
49export function resultText(level: Level): string {
50  return `Effort is now ${level}. It applies from your next step.`
51}
52
53// FNV-1a over the UTF-16 code units of `s`, as an unsigned 32-bit number.
54function fnv1a32(s: string): number {
55  let h = 0x811c9dc5
56  for (let i = 0; i < s.length; i += 1) {
57    h ^= s.charCodeAt(i)
58    h = Math.imul(h, 0x01000193)
59  }
60  return h >>> 0
61}
62
63// mulberry32: a 32-bit generator, a number in [0, 1) per call.
64function mulberry32(seed: number): () => number {
65  let a = seed | 0
66  return () => {
67    a = (a + 0x6d2b79f5) | 0
68    let t = Math.imul(a ^ (a >>> 15), 1 | a)
69    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
70    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
71  }
72}
73
74// The random mode's chain, one level per call: the first is 'low'; each later call draws one number u and switches
75// low -> xhigh when u < pUp, xhigh -> low when u < pDown. The generator is seeded with seed ^ fnv1a32(turnId).
76function levelChain(pUp: number, pDown: number, seed: number, turnId: string): () => Level {
77  const rand = mulberry32(seed ^ fnv1a32(turnId))
78  let cur: Level | null = null
79  return () => {
80    if (cur === null) {
81      cur = 'low'
82    } else {
83      const u = rand()
84      if (cur === 'low' && u < pUp) cur = 'xhigh'
85      else if (cur === 'xhigh' && u < pDown) cur = 'low'
86    }
87    return cur
88  }
89}
90
91// The levels of main requests 0 .. n - 1 of a random arm whose session began with turn `turnId`.
92export function randomLevels(pUp: number, pDown: number, seed: number, turnId: string, n: number): Level[] {
93  const next = levelChain(pUp, pDown, seed, turnId)
94  const out: Level[] = []
95  for (let i = 0; i < n; i += 1) out.push(next())
96  return out
97}
98
99function isLevel(x: unknown): x is Level {
100  return x === 'low' || x === 'xhigh'
101}
102
103function isProbability(x: unknown): x is number {
104  return typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1
105}
106
107function parseConfig(text: string): ModConfig {
108  let raw: any
109  try {
110    raw = JSON.parse(text)
111  } catch (err) {
112    throw new Error(`config.json is not JSON: ${err instanceof Error ? err.message : String(err)}`)
113  }
114  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('config.json is not an object')
115  if (typeof raw.logPath !== 'string' || raw.logPath === '') throw new Error('config.json: logPath must be a non-empty string')
116  const logPath: string = raw.logPath
117  switch (raw.mode) {
118    case 'toggle':
119    case 'placebo':
120      return { mode: raw.mode, logPath }
121    case 'schedule': {
122      const s = raw.schedule
123      if (!Array.isArray(s) || s.length === 0 || !s.every(isLevel)) {
124        throw new Error('config.json: schedule must be a non-empty list of "low" and "xhigh"')
125      }
126      return { mode: 'schedule', schedule: s.slice(), logPath }
127    }
128    case 'random': {
129      const r = raw.random
130      if (r === null || typeof r !== 'object' || !isProbability(r.pUp) || !isProbability(r.pDown) || !Number.isSafeInteger(r.seed)) {
131        throw new Error('config.json: random needs pUp and pDown in [0, 1] and an integer seed')
132      }
133      return { mode: 'random', random: { pUp: r.pUp, pDown: r.pDown, seed: r.seed }, logPath }
134    }
135    default:
136      throw new Error(`config.json: unknown mode ${JSON.stringify(raw.mode)}`)
137  }
138}
139
140type FsWriter = { fs: { write: (path: string, text: string) => Promise<unknown> } }
141type Sink = { path: string; lines: string[]; writing: Promise<void> }
142
143// Appends a line and rewrites the file. Writes are serialised, and each writes every line so far, so a failed write is
144// repaired by the next one and parallel callers lose no line. Never rejects. A top-level function, because the engine
145// follows `$` only into functions declared at the top of this file.
146function appendLog($: FsWriter, sink: Sink, line: object): Promise<void> {
147  sink.lines.push(JSON.stringify(line))
148  sink.writing = sink.writing
149    .then(() => $.fs.write(sink.path, sink.lines.join('\n') + '\n'))
150    .then(
151      () => undefined,
152      () => undefined,
153    )
154  return sink.writing
155}
156
157export const register: Register = on => {
158  let cfg: ModConfig | null = null
159  const sink: Sink = { path: FALLBACK_LOG, lines: [], writing: Promise.resolve() }
160  let requests = 0 // main requests taken so far; the next main step is request number `requests`
161  let mainLevel: Level | null = null // the level of the main loop now; null until one is chosen
162  let chain: (() => Level) | null = null
163
164  on('session.start', async ($, e, next) => {
165    try {
166      let text: string
167      try {
168        text = await $.fs.read(`${$.plugin.root}/config.json`)
169      } catch (err) {
170        throw new Error(`cannot read config.json: ${err instanceof Error ? err.message : String(err)}`)
171      }
172      const parsed = parseConfig(text)
173      if (parsed.mode !== 'schedule') {
174        try {
175          await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
176        } catch (err) {
177          throw new Error(`tool.register failed: ${err instanceof Error ? err.message : String(err)}`)
178        }
179      }
180      cfg = parsed
181      sink.path = parsed.logPath
182    } catch (err) {
183      cfg = null
184      await appendLog($, sink, { type: 'error', message: err instanceof Error ? err.message : String(err) })
185    }
186    return next(e)
187  })
188
189  on('tool.call', { tool: FULL_TOOL_NAME }, async ($, e, next) => {
190    if (cfg === null) return next(e)
191    const { tool: _tool, tool_use_id: _id, agentId: rawAgent, consent: _consent, ...input } = e as Record<string, unknown>
192    const agentId = typeof rawAgent === 'string' ? rawAgent : null
193    const request = Math.max(requests - 1, 0) // the main request whose response made this call
194    const { level, reason } = input
195    if (!isLevel(level) || typeof reason !== 'string' || reason.trim() === '') {
196      await appendLog($, sink, { type: 'invalid', request, agentId, input })
197      return { deny: DENY_TEXT }
198    }
199    // Set before any await, so calls answered in one message apply in the order their hooks start: the last one wins.
200    if (cfg.mode === 'toggle') mainLevel = level
201    const text = resultText(level)
202    await appendLog($, sink, { type: 'call', request, agentId, level, reason, text })
203    return { result: text }
204  })
205
206  on('turn.step', async function* ($, e, next) {
207    if (cfg === null) return yield* next(e)
208    let request: number | null = null
209    if (typeof e.agentId !== 'string') {
210      request = requests
211      requests += 1
212      if (cfg.mode === 'schedule') {
213        mainLevel = cfg.schedule[Math.min(request, cfg.schedule.length - 1)]
214      } else if (cfg.mode === 'random') {
215        chain ??= levelChain(cfg.random.pUp, cfg.random.pDown, cfg.random.seed, e.turnId)
216        mainLevel = chain()
217      }
218    }
219    const rewrite = mainLevel !== null && mainLevel !== e.effort
220    const sent = rewrite ? { ...e, effort: mainLevel as Level } : e
221    await appendLog($, sink, {
222      type: 'step',
223      request,
224      index: e.index,
225      agentId: typeof e.agentId === 'string' ? e.agentId : null,
226      turnId: e.turnId,
227      before: e.effort ?? null,
228      after: sent.effort ?? null,
229    })
230    return yield* next(sent)
231  })
232}
233