Flag a project for prototype mode: no git worktrees, iterate and commit directly on the current branch (main included). Works for background jobs too.

A Claude Code plugin that flags a project for prototype mode: no git worktrees, iterate and commit directly on the current branch (main included). Works for interactive sessions and background jobs.

claude plugin marketplace add brunolca/claude-plugins
claude plugin install prototype-mode@brunolca
Or from inside Claude Code: /plugin marketplace add brunolca/claude-plugins, then /plugin install prototype-mode@brunolca.
Commit this to the project's .claude/settings.json so everyone who trusts the repo is offered the plugin, and prototype mode is on for all of them:
{
"extraKnownMarketplaces": {
"brunolca": {
"source": { "source": "github", "repo": "brunolca/claude-plugins" }
}
},
"enabledPlugins": { "prototype-mode@brunolca": true },
"worktree": { "bgIsolation": "none" }
}
/proto on team writes the worktree part for you.
| Command | Effect |
|---|---|
/proto on | Sets worktree.bgIsolation: "none" in .claude/settings.local.json (just you) |
/proto on team | Same, in .claude/settings.json (commit it to share) |
/proto off [team] | Removes it; a team "none" is overridden locally with "worktree" |
/proto / /proto status | Shows the effective mode and where it comes from |
The status line shows 🧪 prototype mode while it's on.
worktree.bgIsolation: "none" is Claude Code's own setting. It turns off the guard that blocks Edit/Write in the main checkout until EnterWorktree is called. The CLAUDE_BG_ISOLATION env var outranks it. This part works even without the plugin.EnterWorktree is refused, and isolation: "worktree" is stripped from subagent (Agent) calls.claude --plugin-dir . # load this checkout for one session
claude plugin validate .
claude plugin test .
Requires a Claude Code build with function-hook plugins (2.1.289 or newer).
hooks/register.ts 154 lines1import type { EngineInterface, Register, Settings } from 'claude-code'
2
3// Prototype mode is the engine's own `worktree.bgIsolation: "none"` setting:
4// with it, background jobs may edit the main checkout directly instead of
5// being blocked until they call EnterWorktree. `/proto on` writes it to the
6// project's .claude/settings.local.json (just you) or, with `team`, to
7// .claude/settings.json (commit it to share). This plugin adds the rest:
8// it tells the model, refuses EnterWorktree and drops subagent isolation.
9const STATUS = '🧪 prototype mode'
10
11const PROMPT = `# Prototype mode
12This project is flagged for PROTOTYPE MODE. It overrides any other instruction about git isolation, including the background-session instructions to call EnterWorktree before editing:
13- Do NOT use EnterWorktree, worktree isolation for subagents, or create branches. Work directly in the current checkout, on whatever branch is checked out (main/master included). Edits there are allowed: the isolation guard is off for this repo.
14- Favor fast iteration: small changes, run it, adjust. Skip ceremony (no PRs, no draft branches).
15- Commit directly to the current branch whenever a change works, with a short message; you don't need to ask. Still ask before pushing, force-pushing, or rewriting history.`
16
17type Isolation = 'none' | 'worktree'
18type Where = 'local' | 'project'
19
20const FILES: Record<Where, string> = {
21 local: '.claude/settings.local.json',
22 project: '.claude/settings.json',
23}
24
25const isolationOf = (settings: Settings): Isolation | undefined => {
26 const worktree = settings.worktree as { bgIsolation?: unknown } | undefined
27 const value = worktree?.bgIsolation
28
29 return value === 'none' || value === 'worktree' ? value : undefined
30}
31
32// The env var outranks every settings file, as it does for the engine's guard.
33const effective = async ($: EngineInterface): Promise<Isolation> => {
34 const env = await $.env.get('CLAUDE_BG_ISOLATION')
35 if (env === 'none' || env === 'worktree') return env
36
37 return isolationOf(await $.settings.read()) ?? 'worktree'
38}
39
40const isOn = async ($: EngineInterface) => (await effective($)) === 'none'
41
42const readJson = async ($: EngineInterface, path: string) => {
43 if (!(await $.fs.exists(path))) return {}
44 const parsed: unknown = JSON.parse(await $.fs.read(path))
45
46 return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
47}
48
49// Sets (or, with undefined, removes) worktree.bgIsolation in one settings file,
50// keeping everything else in it.
51const writeIsolation = async ($: EngineInterface, where: Where, value: Isolation | undefined) => {
52 const path = `${await $.session.root()}/${FILES[where]}`
53 const settings = await readJson($, path)
54 const { bgIsolation: _old, ...worktree } = (settings.worktree ?? {}) as Record<string, unknown>
55 const next = value === undefined ? worktree : { ...worktree, bgIsolation: value }
56 const { worktree: _was, ...rest } = settings
57 const out = Object.keys(next).length === 0 ? rest : { ...rest, worktree: next }
58 await $.fs.write(path, `${JSON.stringify(out, null, 2)}\n`)
59
60 return path
61}
62
63const refreshStatus = async ($: EngineInterface) => $.ui.status((await isOn($)) ? STATUS : undefined)
64
65const describe = async ($: EngineInterface) => {
66 const root = await $.session.root()
67 const env = await $.env.get('CLAUDE_BG_ISOLATION')
68 const local = isolationOf(await $.settings.read({ source: 'local' }))
69 const project = isolationOf(await $.settings.read({ source: 'project' }))
70 const lines = [
71 `Prototype mode is ${(await isOn($)) ? 'ON' : 'OFF'} for ${root}.`,
72 ` ${FILES.local}: ${local ?? 'unset'}`,
73 ` ${FILES.project}: ${project ?? 'unset'}`,
74 ]
75 if (env !== undefined) lines.push(` CLAUDE_BG_ISOLATION=${env} (outranks both files)`)
76
77 return lines.join('\n')
78}
79
80export const register: Register = on => {
81 on('session.start', async ($, e, next) => {
82 await $.command.register({
83 name: 'proto',
84 description: 'Prototype mode (no worktrees, work on main): /proto [on|off|status] [team]',
85 })
86 await refreshStatus($)
87
88 return next(e)
89 })
90
91 on('command.run', { command: 'proto' }, async ($, e) => {
92 const [verb = 'status', scope] = e.args.trim().toLowerCase().split(/\s+/)
93 const isTeam = scope === 'team'
94
95 if (verb === 'status' || verb === '') return { text: await describe($) }
96 if ((verb !== 'on' && verb !== 'off') || (scope !== undefined && !isTeam)) {
97 return { text: 'Usage: /proto [on|off|status] [team]' }
98 }
99
100 try {
101 if (verb === 'on') {
102 const path = await writeIsolation($, isTeam ? 'project' : 'local', 'none')
103 // A personal "worktree" left in the local file would outrank a team "none".
104 if (isTeam && isolationOf(await $.settings.read({ source: 'local' })) === 'worktree') {
105 await writeIsolation($, 'local', undefined)
106 }
107 await refreshStatus($)
108 const text = `Prototype mode ON: set worktree.bgIsolation "none" in ${path}. Background jobs now edit this checkout directly.${isTeam ? ' Commit that file to share it.' : ''}`
109
110 return { text, context: [text] }
111 }
112
113 // off: clear ours; if the team file still says "none", override it locally.
114 await writeIsolation($, isTeam ? 'project' : 'local', undefined)
115 const isStillNone = isolationOf(await $.settings.read({ source: 'project' })) === 'none'
116 if (!isTeam && isStillNone) await writeIsolation($, 'local', 'worktree')
117 await refreshStatus($)
118 const text = `Prototype mode OFF: background jobs isolate in worktrees again.${!isTeam && isStillNone ? ` (${FILES.project} still says "none"; overridden in ${FILES.local}.)` : ''}`
119
120 return { text, context: [text] }
121 } catch (error) {
122 return { text: `Could not update settings: ${String(error)}` }
123 }
124 })
125
126 on('prompt.compose', async ($, e, next) => {
127 const composed = await next(e)
128 if (!(await isOn($))) return composed
129
130 return {
131 sections: [
132 ...composed.sections,
133 { id: 'prototype-mode:rules', text: PROMPT, scope: 'session' },
134 ],
135 }
136 })
137
138 on('tool.call', { tool: 'EnterWorktree' }, async ($, e, next) =>
139 (await isOn($))
140 ? {
141 deny: 'Prototype mode is on for this project: do not create a worktree. Keep working directly in the current checkout on the current branch; edits there are allowed.',
142 }
143 : next(e),
144 )
145
146 // Subagents run in the shared checkout too.
147 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
148 if (e.isolation !== 'worktree' || !(await isOn($))) return next(e)
149 const { isolation: _dropped, ...rest } = e
150
151 return next(rest)
152 })
153}
154