SLOPSHOPPER

scripted

harness-only scripted model: answers every model request from fixtures

newcommandtimeragents
★ 1v0.0.1no licenseupdated 2026-10-16it-dainb/cadre/harness/scripted
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scripted
› fix the failing auth test and add an audit log call ● scripted: SCRIPTED script lite ● scripted: SCRIPTED turn.complete agent=main reason=answer usage=claude-opus-5-5 ⏺ 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 › /s0 ⎿ scripted: s0 end spawned ● scripted: SCRIPTED spawned end -> {"answer":"","durationMs":0,"isAborted":false,"turnId":"agent-1","reason":"answer"} ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cadre

A Claude Code plugin that makes Claude plan, test, review and merge like a disciplined team, enforced in code, not prompts.

  • You approve in a dialog. Claude drafts a plan; nothing runs until you choose Approve in Claude Code's own question dialog.
  • Tests first. A separate tester writes a failing test, then a worker makes it pass.
  • Isolated and reviewed. Every task gets its own git worktree and an independent review before it merges.
  • A live board in your terminal. See every task, step and agent without leaving Claude Code.
  • Works offline. Tasks live in beads (bd) on your machine. The store is in .cadre/store (not committed); to look at it by hand: BEADS_DIR=.cadre/store bd list. Older projects keep .beads; /cadre doctor offers to move it.
[RUN] cadre · p3 auth-login · strict · contained ✓ · ctx 34% · cache 87%
  ✓a1 ✓a4 ✓a7 │ ▶a2 ✓a3 │ ⏸a6 ◌a5   4/7 done · ▶1 ⏸1 ◌1
[1] Epic  [7] Plan  [8] Doctor  ·  p3 · epic/p3-auth-login · 4/7 done
 ┌────────────────┐     ┏━━━━━━━━━━━━━━━━┓     ┌────────────────┐
 │a1 feat    [OK]✓│     ┃a2 feat   [RUN]▶┃     │a6 feat  [WAIT]⏸│
 │Add login       │━━┳━▶┃Session refresh ┃──┬─▶│Remember me     │
 │●●●●●● merged   │  ┃  ┃●●◉○○○ implement┃  ┃  │○○○○○○ waits a2 │
 └────────────────┘  ┃  ┗━━━━━━━━━━━━━━━━┛  ┃  └────────────────┘

Quick start

claude plugin marketplace add it-dainb/cadre
claude plugin install cadre@cadre

Then, inside Claude Code:

  1. /cadre setup: once per machine. Claude checks your tools and fixes what it can, in the open, and asks your default mode and limits; you confirm the settings in a dialog.
  2. Start work: /cadre <goal in plain words>, e.g. /cadre fix the off-by-one in src/page.ts. In a repo cadre hasn't seen, one dialog asks to set it up first: Claude prepares the task store, your build and test commands and a changelog in the open, you confirm the safety settings, and then the plan comes up with its mission for one approval. Claude picks a lane and says why:
  3. ask: a question or investigation. An explorer answers it; nothing changes.
  4. lite: one small, clear change. One task, drafted for you; choose Approve once and Claude runs it to the end: worker → review → merge (a bug a test can reproduce gets the failing test first).
  5. full: a feature, several tasks, or a public API change. The full plan with scenarios and contracts. Before it plans, Claude sends one explorer through the code and scores how clear the goal is (outcome, scope, acceptance, constraints). A vague goal gets at most 3 questions in one round, naming what the explorer found ("I found Hero, Navbar and Timeline in components/: which one?"); a clear one gets none. Under yolo it asks nothing and the dialog lists what it assumed. /cadre deep <goal> talks it through instead: one question per round at the weakest point, with the scores shown, until it is clear, 8 rounds pass, or you say "enough". Settings → Planning. The lane shows in the approval dialog, with an option to change it; type something instead to ask Claude for a change. Breaking, security, public contract changes and plans of 3+ tasks are always full.
  1. After you approve, it runs by itself. Two modes:
  2. auto (/cadre <goal>): Claude carries the plan until it lands on dev and asks you only at the critical points: a change to the agreed spec, a question only you can answer, an integration that fails twice, a protected branch moved outside cadre, and the release.
  3. yolo (/cadre yolo <goal>): after the approval nothing is asked. Claude decides spec changes and blocked questions itself and each decision is recorded (Activity, Doctor) for you to review. It stops, never releases, when the plan lands on dev. To make yolo your default, set Drive → mode to yolo in Settings (9); a repo's committed config can only force auto. Both stop by themselves when an agent moves a protected branch, the sandbox settings change, or a bound is hit (100 turns, $20, 240 minutes, or 3 turns without progress; Settings → Drive). The band shows auto · turn 12/100 · $1.20 · 0: stop; 0 stops and resumes.

/cadre alone opens the pane. Those forms (/cadre, /cadre <goal>, /cadre yolo <goal>, /cadre deep <goal>, setup, init, doctor, continue) are all you type; every decision is answered in a dialog. It opens once by itself; after that 1 (on the band or in the pane) opens it again, and the pane lists everything that needs you.

How it works

plan → your approval → tester (test goes red) → worker (goes green)
     → independent reviewer → merge into the epic branch
     → epic lands on dev → you release
WhoDoes
YouApprove plans, resolve flags, cut releases. Every one is a dialog answer.
Main ClaudePlans and manages. It never edits code and has no shell.
Worker agentsTester writes the test, worker writes the code, reviewer checks it. Each works in its own worktree.
cadre itselfOwns git (workers cannot commit or push), runs the checks, enforces every step, never pushes.

More: the loop in detail.

Commands

Type thisWhat it does
/cadre setupOne-time machine setup. Details
/cadreOne line: where things stand and what to type next; opens the pane.
/cadre initRe-run the repo setup on purpose (a first /cadre <goal> does it for you).
/cadre doctorRead-only health check. Safe any time.
/cadre <goal> · /cadre yolo <goal>Start work; after approval it runs to dev, asking at the critical points (auto) or not at all (yolo).
/cadre deep <goal>Talk the goal through first (one question per round, scores shown), then the plan comes up as usual.
/cadre continuePick up a plan an earlier session left (also 0 on the band).
/cadre open · /cadre statusOpen the board pane · print the board as text.
SettingsPress 9 in the pane: every setting, where its value comes from, and buttons to change it.

Every decision is a dialog (1 opens it). The typed fallbacks, config keys and the extension protocol are in docs/commands.md.

Stopping and continuing

  • Stop the run: press 0 on the band (0: stop); 0: resume starts it again with a fresh budget.
  • /clear or compaction: nothing to do. Running agents keep working and the run goes on.
  • Quit, crash, or claude --continue later: the new session shows Unfinished plan … · /cadre continue to pick it up and 0: continue on the band. Nothing restarts by itself. /cadre continue (or 0) re-arms the run in its mode (auto or yolo) and respawns each task that was in flight. Each new agent starts from a checkpoint commit of the old one's worktree, with the diff and the last failing check, so nothing is redone. No waiting for old claims to expire.
  • What a quit can lose: only work that was not on disk yet (the agent's unwritten next step). Every file it had written is kept.
  • Two sessions on one repo: a session that is still running keeps its plan and its tasks; /cadre continue elsewhere says so and takes nothing.
  • Usage limit: the run stops with the time it resets (usage limit until HH:MM UTC on the band) and goes on by itself then; nothing is charged to the task. 0: resume goes on at once.
  • Your checkout is never moved. If you have dev (or main) checked out when work lands, cadre leaves your branch, index and files alone and asks in one dialog: Land brings it into your checkout, keeping your commits and your uncommitted edits (an edit to the same file refuses, and the dialog says so). Switch to another branch and it lands by itself. A release waits until it is in.
  • An agent can always end. Once an agent has handed in its work, cadre ends its turn itself (no tokens); one that repeats the same call 20 times is stopped and its task asks you. limits.silentRuns (runs without a report before a task blocks) and limits.startFails (failed starts before the run stops) are in Settings.

Requirements

  • Claude Code 2.1.295 (tested; newer usually works and only warns)
  • git
  • bd ≥ 1.3.0. /cadre setup installs it.
  • Linux: bwrap (bubblewrap) and socat for sandboxed checks. macOS: setup tells you what is needed.

Troubleshooting

  • Run /cadre doctor first. It lists every failing check and how to fix it. The full report is in the pane's Doctor tab.
  • "Ready when you are": cadre isn't set up in this repo yet; /cadre <goal> sets it up and plans the goal.
  • Ubuntu 24.04+ blocks bwrap (AppArmor). cadre never runs root commands for you. It writes a fix script to ~/.cache/cadre/fix/. Read it, then run the line Claude shows you, e.g. sudo sh ~/.cache/cadre/fix/bwrap-apparmor.sh.
  • Known limits: docs/limitations.md.

Philosophy

  • Enforced by code. Hooks deny what the rules forbid. A prompt can be ignored; a denial cannot.
  • Main plans, never edits. The agent that talks to you cannot touch your source.
  • Computed beats claimed. Tests pass because cadre ran them, not because an agent said so.
  • Local first. Your tasks and git history stay on your machine. cadre never pushes.
  • Calm UI, zero token cost. The board is drawn by the plugin. It adds nothing to Claude's context.

Read more: DESIGN · UI design · decisions (ADRs) · UI dev loop · test harness · module contracts

Development

You need the Claude Code 2.1.295 binary (not whatever claude is on PATH).

CC=<path to claude 2.1.295>
node scripts/budget.mjs                      # size budget
$CC plugin validate --strict . && $CC plugin test .
$CC --plugin-dir .                           # try it in the repo you want to manage
node --experimental-transform-types scripts/ui-dev.mjs   # the pane in your terminal, hot reload

Live runs go through Docker: harness/run.sh (see harness/README.md).

Status

Version 0.14.0. Pre-1.0, pinned to Claude Code 2.1.295 (agent exits tested up to 2.1.299). License: not chosen yet.

Source 1 files
hooks/register.ts 118 lines
1// Harness-only scripted model (S0): answers every model request at turn.step (TM-04) from the fixtures below, never calling next, so no request
2// reaches the API (PROXY=1's effort.jsonl stays empty). Each answer is a function of the loop's own transcript ($.session.messages), never of a
3// counter: a resumed or nudged agent gets the answer its transcript calls for. Loaded with a second --plugin-dir AFTER cadre's, so it sits inner to
4// cadre in the user tier (load order): cadre's turn.step sees each scripted response come up from beneath (its cost ledger records `model: scripted`).
5// Every line it logs starts with SCRIPTED (debug log). `/s0 <end|hb|wedge>` is the forced-exit / TaskStop probe (RESULTS.md (c), (d)).
6// The script is chosen per cell by the repo's `.git/scripted.json` ({"script": …}, written by a harness/fixtures/scripted overlay), default `lite`:
7//   lite: main plans one lite task; full: a full-lane task (tester, worker, reviewer); loop: lite, the worker calls the same tool forever (the repeat-call breaker, then forced exit);
8//   limit: lite, the first worker's first step goes to the proxy, which answers a usage limit (stub-proxy.mjs); slow: lite, the worker waits 45 s first.
9import type { Register } from 'claude-code'
10
11type Row = { role: 'user' | 'assistant'; text: string; toolUses?: { tool: string; input?: any }[]; toolResults?: { text?: string; isError?: boolean }[] }
12type Act = { text?: string; tool?: string; input?: unknown }
13const U = { input_tokens: 7, output_tokens: 3, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, model: 'scripted' }   // tokensOf = 7 + 5*3 = 22 per step
14const L = ($: any, t: string) => $.ui.log(`SCRIPTED ${t}`)
15const id = () => `toolu_s0${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`
16const isText = (r: Row) => r.role === 'user' && !r.toolResults?.length && !!r.text
17const since = (rows: Row[]) => { let i = rows.length - 1; while (i > 0 && !isText(rows[i])) i--; return rows.slice(i) }   // from the last prompt/brief/nudge on
18const done = (rows: Row[]) => rows.filter(r => r.role === 'assistant').length
19const END = 'Done. Stopping here.'
20let script = 'lite', injected = false
21const cwds: Record<string, string> = {}   // agentId -> the folder cadre spawned it in (agent.spawn): the tester writes there by its full path
22
23// ---- fixtures: titleCase(s) on the `single` repo, lite lane ----
24const GOAL = /Goal from the user/
25function main(rows: Row[]): Act {
26  const g = rows.map(r => isText(r) && GOAL.test(r.text)).lastIndexOf(true)
27  if (g < 0) return { text: 'OK.' }
28  const after = rows.slice(g + 1), calls = after.flatMap(r => (r.role === 'assistant' ? r.toolUses ?? [] : []))
29  const has = (f: (c: { tool: string; input?: any }) => boolean) => calls.some(f)
30  const kind = (k: string) => has(c => c.tool === 'TaskCreate' && c.input?.metadata?.kind === k)
31  const ai = after.findIndex(r => r.role === 'assistant' && r.toolUses?.some(c => c.tool === 'Agent'))
32  const answered = ai >= 0 && after.slice(ai + 1).some(r => isText(r) || r.toolResults?.some(t => t.text && !/launched/i.test(t.text)))
33  if (after.filter(r => r.role === 'assistant').length > 8) return { text: 'Stopping: approve the plan in the dialog.' }
34  if (ai < 0) return { tool: 'Agent', input: { description: 'Explore the library', subagent_type: 'Explore', prompt: 'What in the project does adding titleCase(s) touch: where string helpers live, how they are exported, where the tests are?' } }
35  if (!answered) return { text: 'Waiting for the explorer.' }
36  if (!kind('clarity')) return { tool: 'TaskCreate', input: { subject: 'clarity', description: 'Clarity of the goal after exploring.', metadata: { kind: 'clarity', outcome: 1, scope: 1, acceptance: 1, constraints: 1 } } }
37  const mission = /There is no mission yet/.test(rows[g].text) ? { mission: 'Goal: add titleCase(s) to the library\nConstraints: none\nNon-goals: none' } : {}
38  if (script === 'full') {
39    if (!kind('plan')) return { tool: 'TaskCreate', input: { subject: 'Add titleCase(s) to the library', description: 'A new public function titleCase(s) with node:test tests.', metadata: { kind: 'plan', lane: 'full', reason: 'a new public function', ...mission } } }
40    if (!has(c => c.tool === 'TaskCreate' && !c.input?.metadata?.kind)) return { tool: 'TaskCreate', input: { subject: 'titleCase(s) with node:test tests', description: 'Create src/titleCase.mjs exporting titleCase: uppercase the first letter of each whitespace-separated word, leave the rest unchanged.',
41      metadata: { type: 'feature', package: '.', paths: ['src/titleCase.mjs', 'test/titleCase.test.mjs'], scenarios: [`S1: Given 'hello big world' When titleCase is called Then it returns 'Hello Big World'`],
42        changelog: { category: 'Added', entry: 'titleCase(s) capitalises the first letter of each word' }, interface: { symbols: [{ name: 'titleCase', kind: 'function', params: ['s: string'], returns: 'string', status: 'new' }] } } } }
43    return { text: 'The plan is drafted: approve it in the dialog (key 1 opens it).' }
44  }
45  if (!kind('plan')) {
46    return { tool: 'TaskCreate', input: { subject: 'Add titleCase(s) with tests', description: 'Add titleCase(s) as a named export in src/titleCase.mjs, in the style of src/slug.mjs, with node:test tests in test/titleCase.test.mjs.',
47      metadata: { kind: 'plan', lane: 'lite', reason: 'one small function and its test', type: 'feature', changelog: { category: 'Added', entry: 'titleCase(s) capitalises the first letter of each word' }, ...mission } } }
48  }
49  return { text: 'The plan is drafted: approve it in the dialog (key 1 opens it).' }
50}
51const TEST = `import { test } from 'node:test'
52import assert from 'node:assert/strict'
53import { titleCase } from '../src/titleCase.mjs'
54test('S1: titleCase capitalises each word', () => assert.equal(titleCase('hello big world'), 'Hello Big World'))
55`
56// the test file only when there is none (full lane: the tester's is locked)
57const FILES = String.raw`mkdir -p src test && cat > src/titleCase.mjs <<'EOF'
58export const titleCase = s => s.replace(/(^|\s)(\S)/g, (_, a, b) => a + b.toUpperCase())
59EOF
60test -f test/titleCase.test.mjs || cat > test/titleCase.test.mjs <<'EOF'
61${TEST}EOF`
62const taskOf = (brief: string) => brief.match(/TASK-([\w-]+\.\d+)/)?.[1] ?? brief.match(/\b([a-z][\w]*-[a-z0-9]+\.\d+)\b/)?.[1]
63const WAIT = { tool: 'Bash', input: { command: 'node -e "setTimeout(() => {}, 45000)"', description: 'Wait' } }
64const ROLES: Record<string, (k: number, brief: string, cwd: string) => Act> = {
65  worker: (k, b) => script === 'loop' ? { tool: 'mcp__cadre__run_check', input: { name: 'test', task: taskOf(b) } } : (script === 'slow' ? [WAIT] : []).concat([{ tool: 'Bash', input: { command: FILES, description: 'Write titleCase and its test' } }, { tool: 'mcp__cadre__run_check', input: { name: 'test', task: taskOf(b) } },
66    { tool: 'mcp__cadre__report', input: { schemaVersion: 1, role: 'worker', outcome: 'done', summary: 'Added titleCase(s) in src/titleCase.mjs with a node:test test; npm test passes.', risks: [] } }])[k] ?? { text: END },
67  tester: (k, _b, cwd) => [{ tool: 'Write', input: { file_path: `${cwd}/test/titleCase.test.mjs`, content: TEST } },
68    { tool: 'mcp__cadre__report', input: { schemaVersion: 1, role: 'tester', outcome: 'done', summary: 'One test per scenario in test/titleCase.test.mjs.', tests: [{ path: 'test/titleCase.test.mjs', name: 'S1: titleCase capitalises each word', scenario: 'S1' }], run: { command: ['node', '--test', 'test/titleCase.test.mjs'] } } }][k] ?? { text: END },
69  reviewer: (k, b) => [{ tool: 'mcp__cadre__run_check', input: { name: 'test', task: taskOf(b) } },
70    { tool: 'mcp__cadre__report', input: { schemaVersion: 1, role: 'reviewer', verdict: 'pass', summary: 'The commit adds titleCase and its test only; npm test passes.', findings: [], coverage: script === 'full' ? [{ scenario: 'S1', test: 'S1: titleCase capitalises each word', ok: true }] : [], contractStatus: script === 'full' ? [{ name: 'titleCase', ok: true }] : [] } }][k] ?? { text: END },
71  explorer: k => [{ tool: 'mcp__cadre__report', input: { schemaVersion: 1, role: 'explorer', answer: 'One string helper, slug, in src/slug.mjs as a named ESM export; tests in test/ use node:test; npm test runs node --test.', citations: [{ path: 'src/slug.mjs', line: 1 }, { path: 'package.json', line: 1 }] } }][k] ?? { text: END },
72}
73function agent(rows: Row[], cwd: string): [string, Act] {
74  const brief = rows.find(isText)?.text ?? '', probe = brief.match(/S0-PROBE tag=(\w+)/)?.[1]
75  const k = done(since(rows))   // auto mode: a role's text + end_turn is re-asked 3 times for SubagentHandback, then the run ends (RESULTS.md (c))
76  if (done(rows) > (script === 'loop' ? 40 : 15)) return ['cap', { text: END }]   // loop: past the breaker (20 in a row)
77  if (probe === 'end') return [probe, { text: END }]
78  if (probe === 'hb') return [probe, /No such tool available: SubagentHandback/.test(JSON.stringify(rows.at(-1))) ? { text: END } : { text: END, tool: 'SubagentHandback', input: { message: END } }]   // the tool exists in auto mode only
79  if (probe === 'wedge') return [probe, k === 0 ? { tool: 'Bash', input: { command: 'node -e "setTimeout(() => {}, 600000)"', description: 'Wait' } } : { text: END }]
80  const role = brief.match(/\b(Worker|Reviewer|Explorer|Tester|Planner) brief/)?.[1]?.toLowerCase() ?? 'unknown'
81  return [role, ROLES[role]?.(k, brief, cwd) ?? { text: END }]
82}
83
84async function* answer(e: any, a: Act): AsyncGenerator<any, any, any> {
85  let i = 0
86  if (a.text) yield { kind: 'text', index: i++, text: a.text }
87  if (a.tool) { yield { kind: 'tool', index: i, id: id(), name: a.tool }; yield { kind: 'input', index: i, json: JSON.stringify(a.input ?? {}) } }
88  const stopReason = a.tool ? 'tool_use' : 'end_turn'
89  yield { kind: 'stop', stopReason, usage: U }
90  return { turnId: e.turnId, index: e.index, answer: a.text ?? '', toolUses: a.tool ? [{ name: a.tool, input: a.input ?? {} }] : [], stopReason, usage: U }
91}
92
93export const register: Register = (on) => {
94  on('session.start', async ($: any, e, next) => {
95    try { script = JSON.parse(await $.fs.read('/work/run/.git/scripted.json')).script ?? script } catch {}
96    await L($, `script ${script}`); await $.command.register({ name: 's0', description: 'scripted probe: /s0 end|hb|wedge', immediate: true }); return next(e)
97  })
98  on('agent.spawn', async ($: any, e: any, next) => { const r = await next(e); if (r?.agentId) cwds[r.agentId] = e.cwd ?? '/work/run'; return r })
99  on('turn.step', async function* ($: any, e: any, next) {
100    const rows = ((await $.session.messages(e.agentId ? { agentId: e.agentId } : {})) ?? []) as Row[]
101    const [who, a] = e.agentId ? agent(rows, cwds[e.agentId] ?? '/work/run') : ['main', main(rows)]
102    if (script === 'limit' && who === 'worker' && !injected) { injected = true; await L($, `step agent=${e.agentId} who=worker index=${e.index} -> next (usage limit from the proxy)`); return yield* next(e) }
103    await L($, `step agent=${e.agentId ?? 'main'} who=${who} index=${e.index} msgs=${e.messageCount} effort=${e.effort ?? '-'} -> ${a.tool ? `${a.text ? 'text+' : ''}tool ${a.tool}` : 'text end_turn'}`)
104    return yield* answer(e, a)
105  })
106  on('turn.complete', async ($: any, e: any, next) => { await L($, `turn.complete agent=${e.agentId ?? 'main'} reason=${e.reason} usage=${e.usage?.model ?? '-'}`); return next(e) })
107  // (c)/(d) probe: spawned from this command frame, so this plugin's turn.step (another registration) still sees the children's steps (G6)
108  on('command.run', { command: 's0' }, async ($: any, e: any) => {
109    const tag = String(e.args ?? '').trim() || 'end'
110    const r = await $.agent.spawn({ prompt: `S0-PROBE tag=${tag}. Do the task you are given.`, description: `s0 ${tag}`, subagentType: 'general-purpose' })
111    await L($, `spawned ${tag} -> ${JSON.stringify(r)}`)
112    if (tag === 'wedge' && r?.agentId) $.clock.after(20_000, async () => {   // a timer frame, as the backstop's: no hook is calling
113      try { await L($, `TaskStop ${r.agentId} -> ${JSON.stringify(await $.tool.call({ tool: 'TaskStop', task_id: r.agentId }))}`) } catch (x) { await L($, `TaskStop ${r.agentId} threw ${x}`) }
114    })
115    return { text: `s0 ${tag} spawned` }
116  })
117}
118