Session modes that are enforced, not requested: investigate, draft, build, ship. Denies the tool calls a stance rules out, auto-switches on phrases like "no…

▄▀▀▀ ▀█▀ ▄▀▀▄ █▄ █ ▄▀▀▀ █▀▀▀
▀▀▄ █ █▀▀█ █ ▀█ █ █▀▀
▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ★ SELECT YOUR CLASS ★

Session modes that are enforced, not requested. In a study of 242 of the author's own Claude Code sessions, "no code, no commit, no PR" was typed as a preamble in 44 of them. Typing it is a request the model can forget. With stance it is a rule: the tool call is refused before it runs.
| Stance | Badge | What it allows |
|---|---|---|
investigate | magnifier | Findings only. Denies Edit/Write/NotebookEdit (except files under the OS temp dir or a session scratchpad), git commit/push/branch <name>/switch -c/checkout -b/worktree add/merge/rebase/cherry-pick/revert/am, gh pr checkout, every gh send that draft denies, and MCP tools whose name holds a write verb (send post create save update delete merge comment reply publish). |
draft | quill | Work locally: no push, PR, issue or MCP post. Denies git push; gh write verbs on any noun (create merge comment review edit close reopen ready delete transfer lock pin upload run rerun cancel set fork rename archive sync enable disable develop add remove ..., for example gh pr review, gh issue comment, gh release create); gh api with a non-GET method or with -f/-F/--input fields (a GraphQL query is allowed, a mutation is not); and MCP write-verb tools. Edits and local commits are fine. |
build | hammer | The default. Stance restricts nothing. |
ship | rocket | Restricts nothing. The band reminds you to verify (tests, CI, review) before merging. |
Every deny names the stance and how to switch, for example:
STANCE INVESTIGATE: git commit is off in INVESTIGATE stance (findings only: no edits, commits or PRs).
Report instead, or ask the person to switch with /stance draft or /stance build.
/plugin marketplace add pourya7/claude-code-mods
/plugin install stance@claude-code-mods
| Command | What it does |
|---|---|
/stance | Shows the current stance and who set it: (default), (auto-detected) or (set by you). |
/stance <name> | Switches to investigate (or inv), draft, build or ship. Runs at once, even mid-turn. |
Auto-detect. When you type a prompt, these phrases (any case) switch the stance and toast STANCE → INVESTIGATE:
investigation task, investigation only, no code, no commit(s), no PR(s), findings only switch to investigate.do not send/post/reply (or don't), draft only switch to draft.When both kinds match, the stricter stance (investigate) wins. Auto-detect only reads prompts you typed (terminal, Remote Control or SDK), not peer or task messages. It can tighten the stance you chose with /stance or a band button, and it may loosen a stance it set itself, but never below the one you chose. When a phrase asks for less than your choice it toasts STANCE KEPT: INVESTIGATE (SET BY YOU), naming the stance you chose.
Telling the model. Outside build, every prompt carries a short note with the stance's rules as prompt context. /stance <name> also leaves that note for the model. A subagent spawned with the Agent tool gets the note at the top of its prompt, and the subagent's own tool calls go through the same checks.
| Option | Default | What it does |
|---|---|---|
autoDetect | true | Switches stance when a prompt uses one of the phrases above. |
showBuild | false | Shows the band in build too. By default the band hides in build. |
The band sits above the prompt: the class badge in half-block pixels, the stance name in its colour (investigate blue, draft yellow, build orange, ship lime), a one-line rule, and buttons that switch on click. With the band focused (ctrl+x tab), the hotkeys i, d, b and s press them. The band hides in build and while a survey is showing. In VS Code and claude -p, where no band draws, the status line reads STANCE ▶ INVESTIGATE and /stance answers in text.
Text capture of the band on the terminal (colours dropped):
▄▀▀█▀▄ STANCE ★ INVESTIGATE
██████ FINDINGS ONLY. NO EDITS, COMMITS OR PRS.
▀▀▀▀▄ [ INV ] [ DRAFT ] [ BUILD ] [ SHIP ]
▀█▄
The other badges:
draft build ship
▄▀█ █▀▀▀▀▀█ ▄██▄
▄▀▀▀ ▀▀▀▀▀▀▀ █▀▀█
█▀▀ ██ ▄████▄
▄▀ ██ ▀ ▀▀ ▀
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
| None | None | None read or written. It reads only the TMPDIR variable, to know which paths count as temp. | No | No | None. Outside build, the stance note is added to your own prompts and Agent prompts, which go to your own model as usual. |
State: the stance lives in session state ($.state, stance.current) and resets with each new session.
git and gh. The parser respects quotes and splits on ;, &, |, newlines, subshells ( ), groups { }, $( ) and backticks (also inside double quotes). It drops env assignments and wrappers (env sudo nice nohup time timeout xargs command exec stdbuf), takes the basename of the command (/usr/bin/git), opens sh/bash/zsh -c '...' and eval ..., skips git -C dir/-c k=v, and reads attached short flags (checkout -bfoo). Not caught: aliases and shell functions, scripts and Makefiles, git reached through a variable ($G push) or another interpreter (python -c, node -e), here-docs fed to a shell, and git plumbing that writes refs (update-ref, commit-tree). The check is a seatbelt, not a sandbox.echo > file). It stops the edit tools.hooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StanceSource, StanceState } from '../types'
5import { autoSwitch, detectStance } from './detect'
6import { checkToolCall } from './policy'
7import { BADGES, spriteRows } from './sprites'
8import { STANCES, STANCE_INFO, modelNote, parseStance } from './stances'
9import type { Stance } from './stances'
10
11const current = atom({ plugin: 'stance', key: 'current' } as const, { stance: 'build', source: 'default' } as StanceState)
12
13const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
14const LABEL_PINK = '#FF77A8'
15const LIGHT_GREY = '#C2C3C7'
16
17const statusText = (stance: Stance): string | undefined =>
18 stance === 'build' ? undefined : `STANCE ▶ ${STANCE_INFO[stance].title}`
19
20/** The person chose `stance` with /stance or a band button. */
21const choose = async ($: EngineInterface, stance: Stance): Promise<void> => {
22 await update($, current, () => ({ stance, source: 'person', personStance: stance }))
23 $.ui.status(statusText(stance))
24}
25
26/** Auto-detect switched to `stance`; the person's own choice stays the floor. */
27const autoSet = async ($: EngineInterface, stance: Stance): Promise<void> => {
28 await update($, current, state => ({ stance, source: 'auto', personStance: state.personStance }))
29 $.ui.status(statusText(stance))
30}
31
32const SOURCE_LABEL: Record<StanceSource, string> = { default: ' (default)', person: ' (set by you)', auto: ' (auto-detected)' }
33
34/** The session's TMPDIR as an extra temp root; none when it cannot be read. */
35const tempRootsOf = async ($: EngineInterface): Promise<string[]> => {
36 try {
37 const tmpdir = await $.env.get('TMPDIR')
38 return tmpdir ? [tmpdir] : []
39 } catch {
40 return []
41 }
42}
43
44const usage = `Usage: /stance [${STANCES.join('|')}]`
45
46export const register: Register = (on, options) => {
47 const isAutoDetect = options.autoDetect !== false
48 const isBuildShown = options.showBuild === true
49
50 on('session.start', async ($, e, next) => {
51 await $.command.register({
52 name: 'stance',
53 description: 'Show or switch the session stance (investigate, draft, build, ship)',
54 argumentHint: '[investigate|draft|build|ship]',
55 immediate: true,
56 })
57 const { stance } = await read($, current)
58 $.ui.status(statusText(stance))
59 return next(e)
60 })
61
62 on('command.run', { command: 'stance' }, async ($, e) => {
63 const wanted = e.args.trim()
64 if (wanted === '') {
65 const { stance, source } = await read($, current)
66 const info = STANCE_INFO[stance]
67 return { text: `STANCE: ${info.title}${SOURCE_LABEL[source]} - ${info.tagline}\n${usage}` }
68 }
69 const stance = parseStance(wanted)
70 if (stance === undefined) return { text: `Unknown stance "${wanted}". ${usage}` }
71 await choose($, stance)
72 const info = STANCE_INFO[stance]
73 const note = modelNote(stance) ?? '[stance] The session is back in BUILD stance: no restrictions from the stance mod.'
74 return { text: `STANCE → ${info.title}. ${info.tagline}`, context: [note] }
75 })
76
77 on('prompt.submit', async ($, e, next) => {
78 if (isAutoDetect && PERSON_ORIGINS.has(e.origin.kind)) {
79 const detected = detectStance(e.text)
80 const state = await read($, current)
81 const decision = detected === undefined ? undefined : autoSwitch(state, detected)
82 if (decision !== undefined && 'to' in decision) {
83 await autoSet($, decision.to)
84 $.ui.toast(`STANCE → ${STANCE_INFO[decision.to].title}`)
85 } else if (decision !== undefined) {
86 $.ui.toast(`STANCE KEPT: ${STANCE_INFO[decision.kept].title} (SET BY YOU)`)
87 }
88 }
89 const { stance } = await read($, current)
90 const note = modelNote(stance)
91 return next(note === undefined ? e : { ...e, context: [...(e.context ?? []), note] })
92 })
93
94 on('tool.call', async ($, e, next) => {
95 const { stance } = await read($, current)
96 if (stance === 'build') return next(e)
97 if (e.tool === 'Agent') {
98 const note = modelNote(stance)
99 return next(note === undefined ? e : { ...e, prompt: `${note}\n\n${e.prompt}` })
100 }
101 const verdict = checkToolCall(stance, e, { tempRoots: await tempRootsOf($) })
102 return verdict === undefined ? next(e) : { deny: verdict }
103 })
104
105 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
106 const { stance } = await read($, current)
107 if (e.props.hasSurvey || (stance === 'build' && !isBuildShown)) return next(e)
108
109 const { Box, Text, Button } = $.ui.resolve(e)
110 const info = STANCE_INFO[stance]
111 const badge = spriteRows(BADGES[stance])
112
113 return (
114 <Box key="band" flexDirection="row" gap={2}>
115 <Box key="badge" flexDirection="column">
116 {badge.map((runs, row) => (
117 <Box key={`badge-${row}`} flexDirection="row">
118 {runs.map((run, index) => (
119 <Text color={run.color} backgroundColor={run.backgroundColor}>
120 {run.text}
121 </Text>
122 ))}
123 </Box>
124 ))}
125 </Box>
126 <Box key="info" flexDirection="column">
127 <Box key="title-row" flexDirection="row">
128 <Text color={LABEL_PINK} bold>
129 {'STANCE ★ '}
130 </Text>
131 <Box key="stance-name">
132 <Text color={info.color} bold>
133 {info.title}
134 </Text>
135 </Box>
136 </Box>
137 <Box key="tagline">
138 <Text color={LIGHT_GREY} wrap="truncate-end">
139 {info.tagline}
140 </Text>
141 </Box>
142 <Box key="buttons" flexDirection="row" gap={1}>
143 {STANCES.map(option => (
144 <Button
145 key={`to-${option}`}
146 label={STANCE_INFO[option].short}
147 hotkey={option[0]}
148 variant={option === stance ? 'primary' : undefined}
149 onPress={() => choose($, option)}
150 />
151 ))}
152 </Box>
153 </Box>
154 </Box>
155 )
156 })
157}
158hooks/detect.ts 29 lines1import { STANCE_INFO } from './stances'
2import type { Stance } from './stances'
3import type { StanceState } from '../types'
4
5const INVESTIGATE_PHRASES = [/\binvestigation (task|only)\b/i, /\bno code\b/i, /\bno commits?\b/i, /\bno PRs?\b/i, /\bfindings only\b/i]
6const DRAFT_PHRASES = [/\b(do not|don't|dont) (send|post|reply)\b/i, /\bdraft only\b/i]
7
8/** The stance a prompt asks for in so many words, the stricter when both match. */
9export const detectStance = (text: string): Stance | undefined => {
10 if (INVESTIGATE_PHRASES.some(phrase => phrase.test(text))) return 'investigate'
11 if (DRAFT_PHRASES.some(phrase => phrase.test(text))) return 'draft'
12 return undefined
13}
14
15export type AutoSwitch = { to: Stance } | { kept: Stance }
16
17/**
18 * What auto-detect does with a detected stance: switch to it, keep the
19 * stricter stance the person chose themselves (and say so), or nothing when
20 * unchanged. A stance auto-detect set may be loosened again by auto-detect,
21 * but never below the stance the person chose.
22 */
23export const autoSwitch = (current: StanceState, detected: Stance): AutoSwitch | undefined => {
24 if (current.stance === detected) return undefined
25 const floor = current.personStance
26 if (floor !== undefined && STANCE_INFO[detected].strictness < STANCE_INFO[floor].strictness) return { kept: floor }
27 return { to: detected }
28}
29hooks/policy.ts 298 lines1import { STANCE_INFO } from './stances'
2import type { Stance } from './stances'
3
4export type PolicyOptions = {
5 /** Extra temp roots, such as the session's TMPDIR. */
6 tempRoots: readonly string[]
7}
8
9type ToolCallLike = { tool: string } & Record<string, unknown>
10
11const DEFAULT_TEMP_ROOTS = ['/tmp/', '/private/tmp/', '/var/tmp/', '/private/var/tmp/', '/var/folders/', '/private/var/folders/']
12
13const WRITE_VERB = /(send|post|create|save|update|delete|merge|comment|reply|publish)/i
14
15const EDIT_TOOLS: Record<string, string> = { Edit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path' }
16
17/** Resolves `.` and `..` segments of an absolute path. */
18const normalize = (path: string): string => {
19 const parts: string[] = []
20 for (const part of path.split('/')) {
21 if (part === '' || part === '.') continue
22 if (part === '..') parts.pop()
23 else parts.push(part)
24 }
25 return `/${parts.join('/')}`
26}
27
28/** True for an absolute path under the OS temp dir or a session scratchpad. */
29export const isTempPath = (path: string, extraRoots: readonly string[]): boolean => {
30 if (!path.startsWith('/')) return false
31 const resolved = normalize(path)
32 if (/\/scratchpad(\/|$)/.test(resolved)) return true
33 const roots = [...DEFAULT_TEMP_ROOTS, ...extraRoots.map(root => (root.endsWith('/') ? root : `${root}/`))]
34 return roots.some(root => `${resolved}/`.startsWith(root))
35}
36
37const SEPARATORS = new Set([';', '&', '|', '\n', '(', ')', '{', '}', '`'])
38const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
39const WRAPPERS = new Set(['command', 'builtin', 'env', 'sudo', 'doas', 'exec', 'time', 'nohup', 'nice', 'xargs', 'timeout', 'stdbuf', 'caffeinate'])
40/** Wrapper options that take the next word as their value. */
41const WRAPPER_VALUE_FLAGS: Record<string, ReadonlySet<string>> = {
42 env: new Set(['-u', '-C', '-S']),
43 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
44 nice: new Set(['-n']),
45 xargs: new Set(['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a']),
46 timeout: new Set(['-s', '-k', '--signal', '--kill-after']),
47 stdbuf: new Set(['-i', '-o', '-e']),
48}
49const MAX_DEPTH = 4
50
51/** Command substitutions (`$(...)`, backticks) inside a double-quoted string. */
52const substitutions = (text: string): string[] =>
53 [...text.matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)].map(match => match[1] ?? match[2] ?? '')
54
55/**
56 * Splits a shell line into simple commands, each a list of words with quotes
57 * removed. Splits outside quotes on `;`, `&`, `|`, newlines, parentheses,
58 * braces, backticks and `$(`, so subshells, groups, background jobs and
59 * command substitutions come out as commands of their own. Substitutions
60 * inside double quotes are split out too.
61 */
62export const shellCommands = (line: string, depth = 0): string[][] => {
63 const commands: string[][] = []
64 let current: string[] = []
65 let word = ''
66 let isWord = false
67 const endWord = () => {
68 if (isWord) current.push(word)
69 word = ''
70 isWord = false
71 }
72 const endCommand = () => {
73 endWord()
74 if (current.length > 0) commands.push(current)
75 current = []
76 }
77 for (let index = 0; index < line.length; index++) {
78 const char = line[index]!
79 if (char === "'") {
80 const close = line.indexOf("'", index + 1)
81 const stop = close === -1 ? line.length : close
82 word += line.slice(index + 1, stop)
83 isWord = true
84 index = stop
85 } else if (char === '"') {
86 let text = ''
87 let at = index + 1
88 while (at < line.length && line[at] !== '"') {
89 if (line[at] === '\\' && at + 1 < line.length) at++
90 text += line[at]
91 at++
92 }
93 word += text
94 isWord = true
95 index = at
96 if (depth < MAX_DEPTH) for (const inner of substitutions(text)) commands.push(...shellCommands(inner, depth + 1))
97 } else if (char === '\\' && index + 1 < line.length) {
98 word += line[index + 1]
99 isWord = true
100 index++
101 } else if (char === ' ' || char === '\t') {
102 endWord()
103 } else if (char === '$' && line[index + 1] === '(') {
104 endCommand()
105 index++
106 } else if (SEPARATORS.has(char)) {
107 endCommand()
108 } else {
109 word += char
110 isWord = true
111 }
112 }
113 endCommand()
114 return commands
115}
116
117const basename = (word: string): string => word.slice(word.lastIndexOf('/') + 1)
118
119/**
120 * The commands a word list runs, after dropping env assignments and wrappers
121 * (`env`, `sudo`, `nice`, `xargs`, `timeout`, ...), taking the basename of the
122 * command word (`/usr/bin/git` is `git`), and opening `sh -c '...'` and
123 * `eval ...` into the commands they run.
124 */
125const unwrap = (list: string[], depth: number): string[][] => {
126 const words = [...list]
127 while (words.length > 0) {
128 const first = words[0]!
129 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
130 words.shift()
131 continue
132 }
133 const name = basename(first)
134 if (!WRAPPERS.has(name)) break
135 words.shift()
136 const valueFlags = WRAPPER_VALUE_FLAGS[name] ?? new Set<string>()
137 while (words.length > 0 && (words[0]!.startsWith('-') || (name === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]!)))) {
138 const option = words.shift()!
139 if (valueFlags.has(option)) words.shift()
140 }
141 if (name === 'timeout' && words.length > 0) words.shift()
142 }
143 if (words.length === 0) return []
144 const name = basename(words[0]!)
145 const rest = words.slice(1)
146 if (depth < MAX_DEPTH && name === 'eval') return commandsIn(rest.join(' '), depth + 1)
147 if (depth < MAX_DEPTH && SHELLS.has(name)) {
148 const flag = rest.findIndex(arg => /^-[A-Za-z]*c[A-Za-z]*$/.test(arg))
149 if (flag !== -1 && rest[flag + 1] !== undefined) return commandsIn(rest[flag + 1]!, depth + 1)
150 }
151 return [[name, ...rest]]
152}
153
154/** Every simple command a shell line runs, unwrapped (see `unwrap`). */
155export const commandsIn = (line: string, depth = 0): string[][] =>
156 shellCommands(line, depth).flatMap(list => unwrap(list, depth))
157
158/** The git subcommand and its args, skipping global options (`-C dir`, `-c k=v`). */
159const gitArgs = (list: string[]): string[] | undefined => {
160 if (list[0] !== 'git') return undefined
161 let index = 1
162 while (index < list.length && list[index]!.startsWith('-')) {
163 const option = list[index]!
164 index += option === '-C' || option === '-c' || option === '--git-dir' || option === '--work-tree' ? 2 : 1
165 }
166 return list.slice(index)
167}
168
169const BRANCH_WRITE_LONG = ['--delete', '--move', '--copy', '--force', '--set-upstream-to', '--unset-upstream', '--edit-description', '--track', '--no-track']
170const BRANCH_LIST_VALUE_FLAGS = new Set(['--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--sort', '--format', '--color', '--abbrev'])
171
172/** True for a short-option cluster (`-qb`, `-bname`) holding one of `letters`. */
173const hasShortFlag = (arg: string, letters: string): boolean =>
174 /^-[^-]/.test(arg) && [...letters].some(letter => arg.slice(1).includes(letter))
175
176const isBranchWrite = (args: string[]): boolean => {
177 for (let index = 0; index < args.length; index++) {
178 const arg = args[index]!
179 if (BRANCH_WRITE_LONG.some(flag => arg === flag || arg.startsWith(`${flag}=`))) return true
180 if (hasShortFlag(arg, 'dDmMcCfut')) return true
181 if (BRANCH_LIST_VALUE_FLAGS.has(arg)) {
182 index++
183 continue
184 }
185 if (!arg.startsWith('-')) return true
186 }
187 return false
188}
189
190const isLongFlag = (arg: string, names: readonly string[]): boolean => names.some(name => arg === name || arg.startsWith(`${name}=`))
191
192/** Names a git write the stance rules out, or undefined. */
193const gitWrite = (stance: Stance, args: string[]): string | undefined => {
194 const [sub, ...rest] = args
195 if (sub === 'push') return 'git push'
196 if (stance !== 'investigate') return undefined
197 switch (sub) {
198 case 'commit':
199 case 'merge':
200 case 'rebase':
201 case 'cherry-pick':
202 case 'revert':
203 case 'am':
204 return `git ${sub}`
205 case 'branch':
206 return isBranchWrite(rest) ? 'git branch (create/delete/rename)' : undefined
207 case 'switch':
208 return rest.some(arg => hasShortFlag(arg, 'cC') || isLongFlag(arg, ['--create', '--force-create', '--orphan'])) ? 'git switch -c' : undefined
209 case 'checkout':
210 return rest.some(arg => hasShortFlag(arg, 'bB') || isLongFlag(arg, ['--orphan'])) ? 'git checkout -b' : undefined
211 case 'worktree':
212 return rest[0] === 'add' ? 'git worktree add' : undefined
213 default:
214 return undefined
215 }
216}
217
218/** gh verbs that change something on the host, whatever the noun. */
219const GH_WRITE_VERBS = new Set([
220 'create', 'merge', 'comment', 'review', 'edit', 'close', 'reopen', 'ready', 'delete', 'transfer', 'lock', 'unlock',
221 'pin', 'unpin', 'upload', 'run', 'rerun', 'cancel', 'set', 'fork', 'rename', 'archive', 'unarchive', 'sync',
222 'enable', 'disable', 'develop', 'add', 'remove', 'update-branch', 'revert',
223])
224/** gh nouns that only touch this machine. */
225const GH_LOCAL_NOUNS = new Set(['config', 'alias', 'extension', 'ext', 'completion', 'auth', 'help'])
226const GH_VALUE_FLAGS = new Set(['-R', '--repo', '-X', '--method', '-H', '--header', '-f', '-F', '--field', '--raw-field', '--input', '-q', '--jq', '-t', '--template', '-b', '--body', '-B', '--base'])
227
228/** True when a `gh api` call sends a write: a non-GET method, or fields (which default to POST). */
229const isApiWrite = (args: string[]): boolean => {
230 let method: string | undefined
231 let hasFields = false
232 for (let index = 0; index < args.length; index++) {
233 const arg = args[index]!
234 if (arg === '-X' || arg === '--method') method = args[++index]
235 else if (arg.startsWith('--method=')) method = arg.slice('--method='.length)
236 else if (/^-X./.test(arg)) method = arg.slice(2)
237 else if (['-f', '-F', '--field', '--raw-field', '--input'].includes(arg) || /^(-[fF].|--(raw-)?field=|--input=)/.test(arg)) hasFields = true
238 }
239 if (method !== undefined) return method.toUpperCase() !== 'GET'
240 if (!hasFields) return false
241 const isGraphql = args.find(arg => !arg.startsWith('-')) === 'graphql'
242 return isGraphql ? args.some(arg => /\bmutation\b/.test(arg)) : true
243}
244
245/** Names a gh write the stance rules out, or undefined. */
246const ghWrite = (stance: Stance, list: string[]): string | undefined => {
247 if (list[0] !== 'gh') return undefined
248 const args = list.slice(1)
249 const positional: string[] = []
250 for (let index = 0; index < args.length; index++) {
251 const arg = args[index]!
252 if (GH_VALUE_FLAGS.has(arg)) index++
253 else if (!arg.startsWith('-')) positional.push(arg)
254 }
255 const [noun, verb] = positional
256 if (noun === undefined) return undefined
257 if (noun === 'api') return isApiWrite(args.slice(args.indexOf('api') + 1)) ? 'gh api (write method)' : undefined
258 if (GH_LOCAL_NOUNS.has(noun)) return undefined
259 if (stance === 'investigate' && noun === 'pr' && verb === 'checkout') return 'gh pr checkout'
260 return verb !== undefined && GH_WRITE_VERBS.has(verb) ? `gh ${noun} ${verb}` : undefined
261}
262
263const denyText = (stance: Stance, what: string): string => {
264 const info = STANCE_INFO[stance]
265 const loosen = stance === 'investigate' ? '/stance draft or /stance build' : '/stance build'
266 return `STANCE ${info.title}: ${what} is off in ${info.title} stance (${info.summary}). Report instead, or ask the person to switch with ${loosen}.`
267}
268
269/**
270 * The deny text when `stance` rules out this tool call, or undefined when it
271 * may run. Build and ship rule out nothing.
272 */
273export const checkToolCall = (stance: Stance, call: { tool: string }, options: PolicyOptions): string | undefined => {
274 if (stance === 'build' || stance === 'ship') return undefined
275 const input = call as ToolCallLike
276
277 if (input.tool.startsWith('mcp__')) {
278 const name = input.tool.split('__').at(-1) ?? ''
279 return WRITE_VERB.test(name) ? denyText(stance, `the MCP write tool ${input.tool}`) : undefined
280 }
281
282 const pathKey = EDIT_TOOLS[input.tool]
283 if (pathKey !== undefined) {
284 if (stance !== 'investigate') return undefined
285 const path = typeof input[pathKey] === 'string' ? (input[pathKey] as string) : ''
286 return isTempPath(path, options.tempRoots) ? undefined : denyText(stance, `${input.tool} of ${path || 'a file'} (only temp and scratchpad files may be written)`)
287 }
288
289 if (input.tool === 'Bash' && typeof input.command === 'string') {
290 for (const list of commandsIn(input.command)) {
291 const git = gitArgs(list)
292 const hit = (git && gitWrite(stance, git)) || ghWrite(stance, list)
293 if (hit) return denyText(stance, hit)
294 }
295 }
296 return undefined
297}
298hooks/sprites.ts 102 lines1import type { Stance } from './stances'
2
3/** PICO-8, keyed by one character each; `.` is transparent. */
4export const PICO8: Record<string, string> = {
5 k: '#000000',
6 n: '#1D2B53',
7 p: '#7E2553',
8 g: '#008751',
9 b: '#AB5236',
10 d: '#5F574F',
11 l: '#C2C3C7',
12 w: '#FFF1E8',
13 r: '#FF004D',
14 o: '#FFA300',
15 y: '#FFEC27',
16 L: '#00E436',
17 B: '#29ADFF',
18 v: '#83769C',
19 P: '#FF77A8',
20 e: '#FFCCAA',
21}
22
23/** The class badge per stance: magnifier, quill, hammer, rocket. 8x8 pixels. */
24export const BADGES: Record<Stance, readonly string[]> = {
25 investigate: [
26 '.wwww...',
27 'wBBwBw..',
28 'wBBBBw..',
29 'wBBBBw..',
30 '.wwww...',
31 '....bb..',
32 '.....bb.',
33 '......bb',
34 ],
35 draft: [
36 '......ww',
37 '.....wlw',
38 '....wlw.',
39 '...wlw..',
40 '..wlw...',
41 '..ww....',
42 '.y......',
43 'y.......',
44 ],
45 build: [
46 'lllllll.',
47 'lwwwwwl.',
48 'lllllll.',
49 '...bb...',
50 '...bb...',
51 '...bb...',
52 '...bb...',
53 '...bb...',
54 ],
55 ship: [
56 '...ww...',
57 '..wwww..',
58 '..wBBw..',
59 '..wwww..',
60 '..wwww..',
61 '.rwwwwr.',
62 '.r.oo.r.',
63 '...yy...',
64 ],
65}
66
67/** One run of same-styled cells in a text row. */
68export type SpriteRun = { text: string; color?: string; backgroundColor?: string }
69
70const cellOf = (top: string, bottom: string): SpriteRun => {
71 const topColor = PICO8[top]
72 const bottomColor = PICO8[bottom]
73 if (topColor && bottomColor) return { text: '▀', color: topColor, backgroundColor: bottomColor }
74 if (topColor) return { text: '▀', color: topColor }
75 if (bottomColor) return { text: '▄', color: bottomColor }
76 return { text: ' ' }
77}
78
79/**
80 * Turns a pixel grid into text rows of half blocks: each text row holds two
81 * pixel rows (`▀` coloured top, background bottom), runs of one style merged.
82 */
83export const spriteRows = (grid: readonly string[]): SpriteRun[][] => {
84 const rows: SpriteRun[][] = []
85 for (let y = 0; y < grid.length; y += 2) {
86 const top = grid[y] ?? ''
87 const bottom = grid[y + 1] ?? ''
88 const runs: SpriteRun[] = []
89 for (let x = 0; x < Math.max(top.length, bottom.length); x++) {
90 const cell = cellOf(top[x] ?? '.', bottom[x] ?? '.')
91 const last = runs.at(-1)
92 if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor && last.text[0] === cell.text) {
93 last.text += cell.text
94 } else {
95 runs.push(cell)
96 }
97 }
98 rows.push(runs)
99 }
100 return rows
101}
102hooks/stances.ts 83 lines1import type { StanceName } from '../types'
2
3export type Stance = StanceName
4
5export const STANCES: readonly Stance[] = ['investigate', 'draft', 'build', 'ship']
6
7export type StanceInfo = {
8 /** The UPPERCASE arcade name. */
9 title: string
10 /** The button label. */
11 short: string
12 /** The PICO-8 colour the name is drawn in. */
13 color: string
14 /** One line under the name in the band. */
15 tagline: string
16 /** The stance in a few plain words, for deny messages. */
17 summary: string
18 /** What the model reads while the stance holds; empty for build. */
19 rules: string
20 /** Higher is stricter; auto-detect never lowers an explicit stance. */
21 strictness: number
22}
23
24export const STANCE_INFO: Record<Stance, StanceInfo> = {
25 investigate: {
26 title: 'INVESTIGATE',
27 short: 'INV',
28 color: '#29ADFF',
29 tagline: 'FINDINGS ONLY. NO EDITS, COMMITS OR PRS.',
30 summary: 'findings only: no edits, commits or PRs',
31 rules:
32 'Findings only. Do not edit files (except under the OS temp dir or the session scratchpad); do not git commit/push/branch/merge/rebase/cherry-pick or create worktrees; do not use gh to create, edit, comment on, review, merge or close PRs, issues or releases, or call gh api with a write method; do not call MCP tools that send, post, create, save, update, delete, merge, comment, reply or publish. Report what you found in chat.',
33 strictness: 3,
34 },
35 draft: {
36 title: 'DRAFT',
37 short: 'DRAFT',
38 color: '#FFEC27',
39 tagline: 'WORK LOCALLY. NO PUSH, PR OR POST.',
40 summary: 'work locally: no push, PR, issue or MCP post',
41 rules:
42 'Work locally: no push, PR, issue or MCP post. Edits and local commits are fine; do not git push; do not use gh to create, edit, comment on, review, merge or close PRs, issues or releases, or call gh api with a write method; do not call MCP tools that send, post, create, save, update, delete, merge, comment, reply or publish. Show drafts in chat instead.',
43 strictness: 2,
44 },
45 build: {
46 title: 'BUILD',
47 short: 'BUILD',
48 color: '#FFA300',
49 tagline: 'FULL POWER. NO LIMITS FROM STANCE.',
50 summary: 'no limits',
51 rules: '',
52 strictness: 1,
53 },
54 ship: {
55 title: 'SHIP',
56 short: 'SHIP',
57 color: '#00E436',
58 tagline: 'VERIFY BEFORE YOU MERGE: TESTS, CI, REVIEW.',
59 summary: 'verify before merging',
60 rules: 'Shipping. Before merging, re-run the tests, check CI on the head commit and read the review comments; report the evidence.',
61 strictness: 1,
62 },
63}
64
65const ALIASES: Record<string, Stance> = {
66 investigate: 'investigate',
67 investigation: 'investigate',
68 inv: 'investigate',
69 draft: 'draft',
70 build: 'build',
71 ship: 'ship',
72}
73
74/** A stance from what the person typed (`INV`, ` draft `), or undefined. */
75export const parseStance = (text: string): Stance | undefined => ALIASES[text.trim().toLowerCase()]
76
77/** What the model reads while `stance` holds, or undefined in build. */
78export const modelNote = (stance: Stance): string | undefined => {
79 const info = STANCE_INFO[stance]
80 if (info.rules === '') return undefined
81 return `[stance] The session is in ${info.title} stance (enforced by the stance mod). ${info.rules} The person switches with /stance <investigate|draft|build|ship>.`
82}
83types/index.d.ts 18 lines1export type StanceName = 'investigate' | 'draft' | 'build' | 'ship'
2
3/** Who put the session in its current stance. */
4export type StanceSource = 'default' | 'person' | 'auto'
5
6export type StanceState = {
7 stance: StanceName
8 source: StanceSource
9 /** The stance the person last chose with /stance or a band button; auto-detect never loosens below it. */
10 personStance?: StanceName
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 stance: { current: StanceState }
16 }
17}
18