A local work board and orchestration loop for Claude Code. Every task saved as a ticket survives context compaction and new sessions, with instructions…

Sidequest is a local board for planning, tracking, and delivering Claude Code work. It keeps a visible backlog across your projects, groups related tickets into stories, and gives Claude a consistent way to delegate, verify, review, and hand work back.
Every task saved as a ticket survives context compaction and new sessions. Its instructions, reproduction steps, status, and recorded discussion stay on the local board, so Claude can retrieve the work after the chat context shrinks.
Setup guide · Generated reference · Toolshed marketplace
Install Sidequest at project scope:
/plugin marketplace add Eigenwise/eigenwise-toolshed
/plugin install sidequest@eigenwise-toolshed --scope project
Reload Claude Code or start a new session. You can also run /quartermaster:setup and let Quartermaster install and configure Sidequest for a project.
Open the board with /sidequest:board, or tell Claude to show it. The dashboard is local and ticket data stays on your machine.
For a small first task, save a checkpoint and read it in a new session before trying a full story.
Tell Claude the outcome you want:
Plan the checkout refresh as a Sidequest story and show me the backlog.
Claude can create the story, split it into tickets, connect dependencies, and classify the work from the board's configured categories. Review the backlog, then ask Claude to dispatch the ready tickets. Claude handles routing, executor startup, verification, and ticket updates. Dispatch uses the model access you configure; any paid subscription or API usage follows that provider's pricing and limits.
When work is ready, ask Claude to review and integrate it:
Review and integrate the submitted checkout tickets if their checks pass.
A codebase ticket hands back a verified revision. Non-Git work hands back a verified project snapshot. Larger or higher-risk changes can get an independent review when the verification evidence needs one.
Ask Claude to save each task as a ticket, including the outcome you want and any reproduction steps. Keep decisions, progress, verification evidence, and the next action in the ticket description or comments as work proceeds. Those records give Claude somewhere to resume after compaction or in another session.
Save this task in Sidequest with the current progress and next steps so we can continue after context compaction.
In the next session, ask:
Check the active Sidequest tickets for this project and continue from their recorded next steps.
Ticket fields and comments persist independently of the conversation. Details that matter for resuming the task need to be recorded on the ticket.
Use the dashboard to switch projects, scan todo, doing, and done work, search and filter tickets, and open full ticket details. You can edit tickets, add comments, connect related work, and set reminders from the board.
Sidequest also works through natural-language requests:
Show me the Sidequest backlog for this project.What is ready to dispatch for the checkout story?Add a ticket for the empty-state bug with the reproduction steps.What is blocking the checkout ticket?Review and integrate the checkout ticket if verification passed.Tell Claude the symptom:
The Sidequest board will not open. Diagnose it.
The checkout ticket will not dispatch. Explain what is blocking it.
A submitted ticket is waiting. Check it and finish the integration if it is safe.
Claude checks the local plugin connection, ticket state, dependencies, configured route, and delivery status, then gives you the next action. After an install or upgrade, start a new Claude Code session or reload plugins so the session picks up the current Sidequest connection and its bundled executors.
For worktree storage, recovery, and command details, see the generated reference. Ask Claude to inspect or clean up local worktrees rather than guessing at lifecycle commands.
Sidequest's plugin code is free and MIT-licensed. If it saves you time, optional donations through Ko-fi or GitHub Sponsors support its maintenance. Donations are never required to install or use the plugin.
MIT (c) Eigenwise
hooks/guards/register.ts 212 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3import {
4 deniedToolsDenial, heredocDenial, inlineWorkNudge, inlineWorkStep, isolatedPeerRow, missingWorktreeDenial,
5 repeatedCommandStep, REPEATED_COMMAND_WARNING, taskOutputDenial, terminalPeerDenial, terminalPeerRow,
6} from './context-guards.js'
7import type { InlineWork } from '../../types/index.js'
8import { destructiveGitDenial, sharedCheckoutGitDenial } from './git-guards.js'
9import { guardFailure } from './guard-errors.js'
10import { registerExecutorAdmission } from './executor-admission.js'
11import { boardExists, caller, machine, projectDirectory, readSnapshot, sessionRows, worktreeExists } from './snapshot.js'
12import { bashWindowsPathVerdict, homeDeleteDenial, powershellShimContext } from './text-guards.js'
13import type { GuardVerdict } from './text-guards.js'
14
15// classic.PreToolUse hands a module only { tool, tool_use_id, ...args } (no agent_type, session_id or cwd),
16// so the guards hook tool.call, whose e also carries agentId inside a subagent (SQ-3743 probe).
17
18const REPEATED_COMMANDS = { plugin: 'sidequest', key: 'repeatedCommands' } as const
19const INLINE_WORK = { plugin: 'sidequest', key: 'inlineWork' } as const
20const AUTOMATION_TAG = /^<(?:agent-message|local-command(?:-caveat)?|task-notification|task-progress|task-result)\b/i
21
22function textField(e: object, name: string): string {
23 const value: unknown = Reflect.get(e, name)
24 return typeof value === 'string' ? value : ''
25}
26
27function withContext(ran: ToolCallResult, context: string | null): ToolCallResult {
28 if (!context || ran.deny !== undefined) return ran
29 return { ...ran, context: [...(ran.context ?? []), context] }
30}
31
32function contextOf(verdict: GuardVerdict): string | null {
33 return verdict && 'context' in verdict ? verdict.context : null
34}
35
36async function isSidequestAgent($: EngineInterface, agentId: string): Promise<boolean> {
37 const agent = (await $.agent.list()).find(candidate => candidate.id === agentId)
38 return /^(?:sidequest:)?sidequest-/.test(agent?.type ?? '')
39}
40
41async function peerMessageDenial($: EngineInterface, to: string): Promise<string | null> {
42 const rows = await sessionRows($)
43 const terminal = terminalPeerRow(rows, to)
44 if (terminal) return terminalPeerDenial(terminal, to)
45 const isolated = isolatedPeerRow(rows, to)
46 return isolated?.worktree && !(await worktreeExists($, isolated.worktree)) ? missingWorktreeDenial(isolated) : null
47}
48
49async function routedBoard($: EngineInterface): Promise<boolean> {
50 const directory = await projectDirectory($, await $.session.cwd())
51 if (!(await boardExists($, directory))) return false
52 return (await readSnapshot($, directory))?.routed !== false
53}
54
55// The main loop only: a subagent's work is its ticket's, and the nudge is about work done without one.
56async function nudgeWanted($: EngineInterface): Promise<boolean> {
57 return (await routedBoard($)) && !(await caller($, undefined))
58}
59
60async function inlineWorkAdvice($: EngineInterface, tool: string, command: string, subagentType: string): Promise<string | null> {
61 const { value = {} } = await $.state.get(INLINE_WORK)
62 const { next, crossed } = inlineWorkStep(value, tool, command, subagentType)
63 if (next !== value) await $.state.set(INLINE_WORK, next)
64 return crossed && (await nudgeWanted($)) ? inlineWorkNudge(next.substantiveActions ?? 0) : null
65}
66
67async function repeatedCommandWarning($: EngineInterface, agentId: string, command: string): Promise<string | null> {
68 if (!(await isSidequestAgent($, agentId))) return null
69 const { value = {} } = await $.state.get(REPEATED_COMMANDS)
70 const step = repeatedCommandStep(value[agentId], command)
71 if (!step) return null
72 await $.state.set(REPEATED_COMMANDS, { ...value, [agentId]: step.next })
73 return step.warn ? REPEATED_COMMAND_WARNING : null
74}
75
76async function deniedToolsGuard($: EngineInterface, e: { tool: string; agentId?: string }): Promise<string | null> {
77 try {
78 return deniedToolsDenial(e.tool, await caller($, e.agentId))
79 } catch (error) {
80 return guardFailure($, 'guard-denied-tools', e.tool, 'closed', error)
81 }
82}
83
84async function inlineWorkGuard($: EngineInterface, e: { tool: string; agentId?: string }): Promise<string | null> {
85 if (e.agentId) return null
86 try {
87 const advice = await inlineWorkAdvice($, e.tool, textField(e, 'command'), textField(e, 'subagent_type'))
88 if (advice) $.ui.toast(advice)
89 return advice
90 } catch (error) {
91 guardFailure($, 'inline-work-nudge', e.tool, 'open', error)
92 return null
93 }
94}
95
96export const register: Register = on => {
97 registerExecutorAdmission(on)
98
99 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
100 try {
101 const denial = homeDeleteDenial(textField(e, 'command'), await machine($))
102 if (denial) return { deny: denial }
103 } catch (error) {
104 return { deny: guardFailure($, 'guard-home-delete', e.tool, 'closed', error)! }
105 }
106 return next(e)
107 })
108
109 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
110 const command = textField(e, 'command')
111 let denial: string | null
112 try {
113 denial = await destructiveGitDenial($, command)
114 } catch (error) {
115 denial = guardFailure($, 'guard-destructive-git', e.tool, 'closed', error)
116 }
117 return denial ? { deny: denial } : next(e)
118 })
119
120 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
121 try {
122 const denial = await sharedCheckoutGitDenial($, textField(e, 'command'), e.agentId)
123 if (denial) return { deny: denial }
124 } catch (error) {
125 return { deny: guardFailure($, 'guard-shared-checkout-git', e.tool, 'closed', error)! }
126 }
127 return next(e)
128 })
129
130 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
131 let verdict: GuardVerdict = null
132 try {
133 if ((await machine($)).windows) verdict = bashWindowsPathVerdict(textField(e, 'command'))
134 } catch (error) {
135 verdict = { deny: guardFailure($, 'guard-bash-windows-paths', e.tool, 'closed', error)! }
136 }
137 if (verdict && 'deny' in verdict) return { deny: verdict.deny }
138 return withContext(await next(e), contextOf(verdict))
139 })
140
141 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
142 try {
143 const denial = heredocDenial(textField(e, 'command'), await caller($, e.agentId), await $.session.cwd())
144 if (denial) return { deny: denial }
145 } catch (error) {
146 return { deny: guardFailure($, 'guard-heredoc-isolated', e.tool, 'closed', error)! }
147 }
148 return next(e)
149 })
150
151 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
152 let context: string | null = null
153 try {
154 if ((await machine($)).windows) context = powershellShimContext(textField(e, 'command'))
155 } catch (error) {
156 guardFailure($, 'guard-powershell-cmd-shims', e.tool, 'open', error)
157 }
158 return withContext(await next(e), context)
159 })
160
161 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
162 let warning: string | null = null
163 try {
164 if (e.agentId) warning = await repeatedCommandWarning($, e.agentId, textField(e, 'command'))
165 } catch (error) {
166 guardFailure($, 'repeated-command-warn', e.tool, 'open', error)
167 }
168 return withContext(await next(e), warning)
169 })
170
171 // The engine takes one matcher-less tool.call hook per module, so the two every-tool guards share it.
172 on('tool.call', async ($, e, next) => {
173 const denial = await deniedToolsGuard($, e)
174 if (denial) return { deny: denial }
175 return withContext(await next(e), await inlineWorkGuard($, e))
176 })
177
178 on('tool.call', { tool: /^TaskOutput$/ }, async ($, e, next) => {
179 try {
180 const denial = e.agentId ? null : taskOutputDenial({ task_id: Reflect.get(e, 'task_id'), id: Reflect.get(e, 'id') }, await sessionRows($), await $.session.id())
181 if (denial) return { deny: denial }
182 } catch (error) {
183 return { deny: guardFailure($, 'guard-task-output', e.tool, 'closed', error)! }
184 }
185 return next(e)
186 })
187
188 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
189 try {
190 const denial = await peerMessageDenial($, textField(e, 'to').trim())
191 if (denial) return { deny: denial }
192 } catch (error) {
193 return { deny: guardFailure($, 'guard-peer-message', e.tool, 'closed', error)! }
194 }
195 return next(e)
196 })
197
198 // A board call silences the inline-work nudge only until the next prompt, so a later inline burst is
199 // still counted; advisorySurfaced stays, so the nudge shows once per session.
200 on('prompt.submit', async ($, e, next) => {
201 try {
202 const { value } = await $.state.get(INLINE_WORK)
203 if (!e.isMeta && !AUTOMATION_TAG.test(e.text.trim()) && value?.boardInteraction) {
204 await $.state.set(INLINE_WORK, { advisorySurfaced: value.advisorySurfaced } satisfies InlineWork)
205 }
206 } catch (error) {
207 guardFailure($, 'inline-work-nudge', 'prompt.submit', 'open', error)
208 }
209 return next(e)
210 })
211}
212hooks/guards/context-guards.ts 196 lines1import { deniedToolMatch } from '../../src/lib/denied-tools.js';
2import type { InlineWork, RepeatedCommand } from '../../types/index.js';
3
4// Pure decisions for the guards that need to know who is calling. The caller is resolved from the
5// store's dispatch snapshot (src/lib/store/dispatch-snapshot.ts); these functions only read rows.
6export type SnapshotRow = {
7 ref: string;
8 sessionId: string | null;
9 claimSessionId: string | null;
10 agentId: string | null;
11 agentName: string | null;
12 executor: string | null;
13 worktree: string | null;
14 sharedTree: boolean;
15 readonly: boolean;
16 evidenceMode?: boolean;
17 evidenceWriteRoots?: string[];
18 evidenceRepositoryRoot?: string | null;
19 state: string;
20 deniedTools: string[];
21 readOnlyDeniedTools?: string[] | null;
22};
23
24export type Snapshot = { at: string; routed: boolean; dispatches: SnapshotRow[] };
25
26const LIVE_STATES = new Set(['prepared', 'launched', 'bound', 'claimed']);
27
28// A subagent is known by its agent id; a teammate in its own pane is the main loop of its own session,
29// so it is known by the session that claimed the ticket. The orchestrator's own session id sits on the
30// rows it dispatched as sessionId, never claimSessionId, so it is not mistaken for its executors.
31export function callerRow(rows: readonly SnapshotRow[], agentId: string | undefined, sessionId: string): SnapshotRow | null {
32 const live = rows.filter((row) => LIVE_STATES.has(row.state));
33 if (agentId) return live.find((row) => row.agentId === agentId) ?? null;
34 return live.find((row) => row.state === 'claimed' && row.claimSessionId === sessionId) ?? null;
35}
36
37export function deniedToolsDenial(toolName: string, row: SnapshotRow | null): string | null {
38 if (!row) return null;
39 const pattern = deniedToolMatch(toolName, row.deniedTools);
40 if (!pattern) return readOnlyMcpDenial(toolName, row);
41 return `sidequest: ${toolName} is denied to executors on ${row.ref} by the board deniedTools setting (${pattern}). Finish the ticket with the remaining tools; when it needs ${toolName}, comment why on the ticket and release it.`;
42}
43
44const READ_ONLY_BOARD_TOOLS = new Set(['comment', 'checkpoint', 'done', 'release', 'scopeRequest'].map(name => `mcp__plugin_sidequest_board__${name}`));
45const MCP_WRITE_WORD = /(^|_)(send|post|create|add|edit|update|delete|remove|archive|transition|write|upload|publish|assign|comment|reply|move|rename)(_|$)/i;
46
47export function mcpWriteWord(toolName: string): string | null {
48 if (!toolName.startsWith('mcp__')) return null;
49 const finalSegment = toolName.slice(toolName.lastIndexOf('__') + 2).replace(/([a-z0-9])([A-Z])/g, '$1_$2');
50 return finalSegment.match(MCP_WRITE_WORD)?.[2] ?? null;
51}
52
53export function readOnlyMcpDenial(toolName: string, row: SnapshotRow): string | null {
54 if (!row.readonly || READ_ONLY_BOARD_TOOLS.has(toolName)) return null;
55 const pattern = Array.isArray(row.readOnlyDeniedTools)
56 ? deniedToolMatch(toolName, row.readOnlyDeniedTools)
57 : mcpWriteWord(toolName);
58 if (!pattern) return null;
59 return `sidequest: ${toolName} is denied to read-only executors on ${row.ref} (matched ${pattern}). board_config readOnlyDeniedTools replaces the whole default MCP write-name deny list, rather than extending it.`;
60}
61
62export const HEREDOC_DENIAL = 'sidequest: the harness refuses heredocs in isolated worktrees; Write the script to your scratchpad and run it by path.';
63
64function skipQuoted(command: string, index: number): number {
65 const quote = command[index];
66 for (let cursor = index + 1; cursor < command.length; cursor += 1) {
67 if (quote === '"' && command[cursor] === '\\') cursor += 1;
68 else if (command[cursor] === quote) return cursor;
69 }
70 return command.length;
71}
72
73function startsComment(command: string, index: number): boolean {
74 const previous = command[index - 1];
75 return previous === undefined || /[\s;&|(]/.test(previous);
76}
77
78export function hasHereDoc(command: string): boolean {
79 for (let index = 0; index < command.length; index += 1) {
80 const character = command[index];
81 if (character === "'" || character === '"') index = skipQuoted(command, index);
82 else if (character === '#' && startsComment(command, index)) {
83 const lineEnd = command.indexOf('\n', index);
84 if (lineEnd < 0) return false;
85 index = lineEnd;
86 } else if (character === '<' && command[index + 1] === '<' && command[index + 2] !== '<') return true;
87 }
88 return false;
89}
90
91export function harnessWorktreeCwd(cwd: string): boolean {
92 const parts = cwd.split(/[\\/]+/).map((part) => part.toLowerCase());
93 return parts.some((part, index) => (
94 part === 'worktrees'
95 && parts.slice(0, index).includes('.claude')
96 && (parts[index + 1]?.startsWith('agent-') === true || parts[index + 2]?.startsWith('agent-') === true)
97 ));
98}
99
100export function heredocDenial(command: string, row: SnapshotRow | null, cwd: string): string | null {
101 if (!hasHereDoc(command)) return null;
102 return row?.sharedTree === false || harnessWorktreeCwd(cwd) ? HEREDOC_DENIAL : null;
103}
104
105export const REPEATED_COMMAND_WARNING = 'sidequest: you have run this exact command 3 times; if you are waiting on something, run it with run_in_background and let the completion notification wake you — polling burns ~14s and ~60k tokens per call';
106
107function warnsAt(count: number, lastWarning: number): boolean {
108 return count >= 3 && (lastWarning === 0 || count - lastWarning >= 5);
109}
110
111export function repeatedCommandStep(previous: RepeatedCommand | undefined, rawCommand: string): { next: RepeatedCommand; warn: boolean } | null {
112 const command = rawCommand.trim().replace(/\s+/g, ' ');
113 if (!command) return null;
114 const { count, lastWarning } = previous?.command === command ? { count: previous.count + 1, lastWarning: previous.lastWarning } : { count: 1, lastWarning: 0 };
115 const warn = warnsAt(count, lastWarning);
116 return { next: { command, count, lastWarning: warn ? count : lastWarning }, warn };
117}
118
119export const TASK_OUTPUT_DENIAL = 'sidequest: native Agent results arrive automatically. Use pulse <ref> / changes --since for liveness. Use TaskStop only after terminal board evidence.';
120
121function sidequestTaskId(value: unknown, dispatchedIds: ReadonlySet<string>): boolean {
122 if (typeof value !== 'string' || !value) return false;
123 const lowered = value.toLowerCase();
124 if (lowered.startsWith('sidequest-') || dispatchedIds.has(value)) return true;
125 return lowered.includes('sidequest') && /@session-[^\s]+/i.test(value);
126}
127
128export function taskOutputDenial(toolInput: { task_id?: unknown; id?: unknown }, rows: readonly SnapshotRow[], sessionId: string): string | null {
129 const dispatchedIds = new Set(rows
130 .filter((row) => sessionId && row.sessionId === sessionId)
131 .flatMap((row) => [row.agentName, row.agentId])
132 .filter((value): value is string => Boolean(value)));
133 return sidequestTaskId(toolInput.task_id, dispatchedIds) || sidequestTaskId(toolInput.id, dispatchedIds) ? TASK_OUTPUT_DENIAL : null;
134}
135
136// A closed row's state is its terminal outcome (the snapshot writer's dispatchRowState).
137export function terminalPeerRow(rows: readonly SnapshotRow[], to: string): SnapshotRow | null {
138 return rows.filter((row) => to && row.agentName === to && !LIVE_STATES.has(row.state)).at(-1) ?? null;
139}
140
141export function terminalPeerDenial(row: SnapshotRow, to: string): string {
142 return `sidequest: ${row.ref} is terminal (${row.state}) and executor "${to}" is closed. `
143 + 'File a follow-up ticket for changes, or redispatch the existing ticket when it was released without a pending submission.';
144}
145
146export function isolatedPeerRow(rows: readonly SnapshotRow[], to: string): SnapshotRow | null {
147 return rows.find((row) => to && row.agentName === to && row.sharedTree === false && row.worktree) ?? null;
148}
149
150export function missingWorktreeDenial(row: SnapshotRow): string {
151 return `sidequest: ${row.ref} is worktree-isolated but its recorded worktree is gone. `
152 + 'Do not resume this executor because it could write to the shared checkout. Redispatch the ticket instead.';
153}
154
155const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
156const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
157export const SUBSTANTIVE_ADVISORY_THRESHOLD = 25;
158
159function isBoardInteraction(toolName: string, command: string, subagentType: string): boolean {
160 if (toolName.startsWith('mcp__plugin_sidequest_board__')) return true;
161 if (toolName === 'Agent') return /^(?:sidequest:)?sidequest-exec/.test(subagentType);
162 return SHELL_TOOLS.has(toolName) && /(?:^|[\s"'\\/])sidequest(?:\.js)?(?=\s|["']|$)/i.test(command);
163}
164
165function isPureRead(command: string): boolean {
166 const parts = command.split(/(?:&&|\|\||;)/).map((part) => part.trim()).filter(Boolean);
167 return parts.every((part) => /^(?:cd\s+\S+|(?:git\s+)?(?:status|diff|log|show|branch\s+--show-current|rev-parse|ls-files)|(?:ls|dir|pwd|cat|head|tail|rg|grep|find|which|where|get-(?:childitem|content|item|location)|select-string|test-path)\b)/i.test(part));
168}
169
170// Test and build runs check work already done; counting them as edits fired the nudge on verification loops.
171function isVerificationRun(command: string): boolean {
172 const parts = command.split(/(?:&&|\|\||;)/).map((part) => part.trim()).filter(Boolean);
173 return parts.every((part) => /^(?:cd\s+\S+$|npm\s+(?:--prefix\s+\S+\s+)?(?:test|t|run\s+(?:test|build|typecheck)\S*)\b|node\s+(?:--import\s+\S+\s+)*(?:-e|--eval|--test)\b|(?:npx\s+)?tsc\b)/i.test(part));
174}
175
176function isSubstantive(toolName: string, command: string): boolean {
177 if (EDIT_TOOLS.has(toolName)) return true;
178 return SHELL_TOOLS.has(toolName) && !isPureRead(command) && !isVerificationRun(command);
179}
180
181export function inlineWorkNudge(substantiveActions: number): string {
182 return `sidequest: ${substantiveActions} substantive actions without a board call. Consider one owner and a focused check for a bounded fix. Small fixes at known locations may stay inline; use a story or wave only for 3+ independently checkable pieces with a stated reason.`;
183}
184
185// Returns the new state, and whether this call crossed the threshold; the caller checks the board only then.
186export function inlineWorkStep(state: InlineWork, toolName: string, command: string, subagentType: string): { next: InlineWork; crossed: boolean } {
187 if (isBoardInteraction(toolName, command.trim(), subagentType)) return { next: { ...state, boardInteraction: true }, crossed: false };
188 return isSubstantive(toolName, command.trim()) ? countSubstantiveAction(state) : { next: state, crossed: false };
189}
190
191function countSubstantiveAction(state: InlineWork): { next: InlineWork; crossed: boolean } {
192 const substantiveActions = (state.substantiveActions ?? 0) + 1;
193 const crossed = !state.boardInteraction && !state.advisorySurfaced && substantiveActions >= SUBSTANTIVE_ADVISORY_THRESHOLD;
194 return { next: { ...state, substantiveActions, advisorySurfaced: state.advisorySurfaced || crossed }, crossed };
195}
196hooks/guards/git-guards.ts 253 lines1import { isReadOnlyGit } from '../../src/hooks/shared/read-only-git.js'
2import type { EngineInterface } from 'claude-code'
3
4import type { SnapshotRow } from './context-guards.js'
5import {
6 branchName, destructiveMatch, destructiveMatches, gitInvocations, invocationDirectory, isTagRef, pushesEverything, samePath, shellTokens, tagAction,
7} from './git-command.js'
8import type { GitInvocation } from './git-command.js'
9import { withoutHereDocBodies } from './heredocs.js'
10import { caller, machine, projectRoot, registeredRoot, sidequestHome, slugify } from './snapshot.js'
11import { resolveAbsolute } from './text-guards.js'
12import type { Machine } from './text-guards.js'
13import type { RepoFacts } from '../../types/index.js'
14
15// git runs only once the command text already names a destructive, publishing or mutating invocation, and
16// the repository behind each directory is asked once per session.
17
18const REPO_ROOTS = { plugin: 'sidequest', key: 'repoRoot' } as const
19const PROCESS_TIMEOUT_MS = 10_000
20export const REPO_FACTS_ARGS = ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir']
21
22type Shell = { command: string; cwd: string; host: Machine }
23type Repo = { root: string; commonDir: string | null }
24type Upstream = { remote: string; branch: string }
25type LockHolder = { sessionId?: unknown; repo?: unknown }
26
27async function git($: EngineInterface, directory: string, args: string[]): Promise<string> {
28 const ran = await $.process.run(['git', ...args], { cwd: directory, timeoutMs: PROCESS_TIMEOUT_MS })
29 return ran.exitCode === 0 ? ran.stdout : ''
30}
31
32// A directory that is not a repository yet is asked again next time, since a later clone or init makes it one.
33async function repoFacts($: EngineInterface, directory: string, host: Machine): Promise<RepoFacts | null> {
34 const { value = {} } = await $.state.get(REPO_ROOTS)
35 const cached = value[directory]
36 if (cached) return cached
37 if (!(await $.fs.exists(directory))) return null
38 const [root, commonDir] = (await git($, directory, REPO_FACTS_ARGS)).split(/\r?\n/)
39 if (!root || !commonDir) return null
40 const facts = { root: resolveAbsolute(root, host), commonDir: resolveAbsolute(commonDir, host) }
41 await $.state.set(REPO_ROOTS, { ...value, [directory]: facts })
42 return facts
43}
44
45async function repoAt($: EngineInterface, shell: Shell, index: number, dashC: string | null): Promise<Repo> {
46 const directory = invocationDirectory(shell.command, index, dashC, shell.cwd, shell.host)
47 const facts = await repoFacts($, directory, shell.host)
48 return facts ?? { root: directory, commonDir: null }
49}
50
51async function shellOf($: EngineInterface, command: string): Promise<Shell> {
52 return { command, cwd: await $.session.cwd(), host: await machine($) }
53}
54
55// Keep the repository path only in the recovery command so long temp paths fit the hook output budget.
56function destructiveRefusal(label: string, repo: string, dirty: string[]): string {
57 const shown = dirty.slice(0, 10).map((line) => ` ${line}`)
58 if (dirty.length > shown.length) shown.push(` … +${dirty.length - shown.length} more`)
59 return [
60 `sidequest: refusing ${label} — the shared checkout (a board-registered project root; your evidence clone is not one) has ${dirty.length} uncommitted change(s) that this operation would destroy.`,
61 ...shown,
62 'Some of this may be a live executor\'s finished work that lost its worktree; the shared tree is not yours alone.',
63 `Next step: preserve every dirty path in a named stash (for example, \`git stash push -u -m "sidequest recovery"\`), then run \`sidequest recover-shared --project "${repo}" --stash <stash@{n}> --yes\`. That exact recovery action verifies the stash before it runs \`git reset --hard && git clean -fd\`.`,
64 ].join('\n')
65}
66
67function publicationRefusal(label: string, repo: string): string {
68 return [
69 `sidequest: refusing ${label} without the current session's publish lock.`,
70 ` repo: ${repo}`,
71 'Acquire it with `sidequest publish lock` before a deliberate release cut, then retry.',
72 'This blocks the entire shell invocation, including earlier compound-command segments.',
73 'This is a local early warning. Server-side GitHub rules remain the guarantee.',
74 ].join('\n')
75}
76
77function sharedTreeCommitRefusal(ref: string): string {
78 return [
79 `sidequest: refusing raw git commit for ${ref} in the shared checkout.`,
80 'Use mcp__plugin_sidequest_board__commit so Sidequest stages and commits only the approved ticket scope.',
81 'If the path needs approval first, request scope before committing.',
82 ].join('\n')
83}
84
85function isolatedMutationRefusal(root: string): string {
86 return `sidequest: refusing a mutating git command against the shared checkout (${root}) from an isolated worktree. The shared checkout means the project's registered root and every other executor's worktree; a clone you created under your ticket's verification directory or scratchpad is not it and stays writable. Read-only git commands such as log, diff, show, rev-parse, and ls-files are allowed for review; make repository changes only in the assigned worktree.`
87}
88
89async function dirtyCheckoutDenial($: EngineInterface, shell: Shell): Promise<string | null> {
90 for (const destructive of destructiveMatches(shell.command)) {
91 const { root } = await repoAt($, shell, destructive.index, destructive.dashC)
92 if (!(await registeredRoot($, root, shell.host))) continue
93 const dirty = (await git($, root, ['status', '--porcelain'])).split(/\r?\n/).filter(Boolean)
94 if (dirty.length) return destructiveRefusal(destructive.label, root, dirty)
95 }
96 return null
97}
98
99async function upstreamOf($: EngineInterface, root: string): Promise<Upstream | null> {
100 const ref = (await git($, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim()
101 const slash = ref.indexOf('/')
102 return slash > 0 ? { remote: ref.slice(0, slash), branch: ref.slice(slash + 1) } : null
103}
104
105async function defaultBranch($: EngineInterface, root: string, remote: string): Promise<string> {
106 const ref = (await git($, root, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`])).trim()
107 if (ref.startsWith(`${remote}/`)) return ref.slice(remote.length + 1)
108 const branches = (await git($, root, ['for-each-ref', '--format=%(refname:short)', `refs/remotes/${remote}`])).split(/\r?\n/)
109 .map((branch) => branch.trim().replace(`${remote}/`, ''))
110 .filter((branch) => branch && branch !== 'HEAD')
111 return ['main', 'master', 'trunk'].find((branch) => branches.includes(branch)) || branches[0] || 'main'
112}
113
114async function pushRemote($: EngineInterface, root: string, tokens: string[], upstream: Upstream | null): Promise<{ remote: string; specs: string[] }> {
115 const positional = tokens.filter((token) => !token.startsWith('-'))
116 const [first, ...rest] = positional
117 const named = first !== undefined && (await git($, root, ['remote', 'get-url', first])).trim() !== ''
118 return named && first ? { remote: first, specs: rest } : { remote: upstream?.remote || 'origin', specs: positional }
119}
120
121function pushDestination(spec: string): string {
122 return spec.includes(':') ? spec.slice(spec.indexOf(':') + 1) : spec
123}
124
125function specPublishes(spec: string, defaultTarget: string, published: string, integration: string): boolean {
126 const destination = pushDestination(spec)
127 if (!destination || isTagRef(destination)) return true
128 const target = destination === 'HEAD' ? defaultTarget : branchName(destination)
129 return target === published && target !== integration
130}
131
132function pushPublishes(specs: string[], defaultTarget: string, published: string, integration: string): boolean {
133 if (specs.length === 0) return defaultTarget === published && defaultTarget !== integration
134 return specs.some((spec) => specPublishes(spec, defaultTarget, published, integration))
135}
136
137function wholesalePush(tokens: string[], specs: string[]): boolean {
138 return pushesEverything(tokens) || specs.some((spec) => isTagRef(spec) || spec.includes('*'))
139}
140
141// The published branch a push lands on, or null when it lands elsewhere (the integration branch included).
142async function publishedPushBranch($: EngineInterface, root: string, args: string, integration: string): Promise<string | null> {
143 const tokens = shellTokens(args)
144 const upstream = await upstreamOf($, root)
145 const { remote, specs } = await pushRemote($, root, tokens, upstream)
146 const published = await defaultBranch($, root, remote)
147 if (wholesalePush(tokens, specs)) return published
148 const defaultTarget = upstream?.branch || (await git($, root, ['branch', '--show-current'])).trim()
149 return pushPublishes(specs, defaultTarget, published, integration) ? published : null
150}
151
152// Only a registered root has a board config; anything else keeps the 'main' default.
153async function integrationBranch($: EngineInterface, root: string, host: Machine): Promise<string> {
154 if (!(await registeredRoot($, root, host))) return 'main'
155 try {
156 const ran = await $.process.run(['node', `${$.plugin.root}/bin/sidequest.js`, 'board-config', '--json', '--project', root], { timeoutMs: PROCESS_TIMEOUT_MS })
157 const config: { integrationBranch?: unknown } = JSON.parse(ran.stdout)
158 return typeof config.integrationBranch === 'string' ? config.integrationBranch : 'main'
159 } catch {
160 // An unavailable board must not make ordinary Git commands fail closed.
161 return 'main'
162 }
163}
164
165async function lockHolder($: EngineInterface, commonDir: string): Promise<LockHolder | null> {
166 try {
167 const holder: LockHolder = JSON.parse(await $.fs.read(`${commonDir}/sidequest-publish.lock`))
168 return holder
169 } catch {
170 return null
171 }
172}
173
174async function publishLockHeld($: EngineInterface, shell: Shell, repo: Repo): Promise<boolean> {
175 const holder = repo.commonDir ? await lockHolder($, repo.commonDir) : null
176 if (!holder || holder.sessionId !== (await $.session.id())) return false
177 return typeof holder.repo !== 'string' || samePath(holder.repo, repo.root, shell.host)
178}
179
180function publicationVerb(invocation: GitInvocation): boolean {
181 return invocation.subcommand === 'push' || (invocation.subcommand === 'tag' && tagAction(invocation.args))
182}
183
184async function publicationLabel($: EngineInterface, shell: Shell, invocation: GitInvocation, root: string): Promise<string | null> {
185 if (invocation.subcommand === 'tag') return 'manual git tag'
186 const integration = await integrationBranch($, root, shell.host)
187 const published = await publishedPushBranch($, root, invocation.args, integration)
188 return published ? `a push to the published branch (${published})` : null
189}
190
191async function publicationDenial($: EngineInterface, shell: Shell): Promise<string | null> {
192 for (const invocation of gitInvocations(shell.command).filter(publicationVerb)) {
193 const repo = await repoAt($, shell, invocation.index, invocation.dashC)
194 if (!(await $.fs.exists(repo.root))) return `sidequest: directory ${repo.root} does not exist (yet). This blocks the entire shell invocation, including earlier directory-creating segments. Next step: run the ${invocation.subcommand} as a separate command after the directory exists.`
195 const label = await publicationLabel($, shell, invocation, repo.root)
196 if (label && !(await publishLockHeld($, shell, repo))) return publicationRefusal(label, repo.root)
197 }
198 return null
199}
200
201// Text first: no git process starts unless the command names a destructive or publishing invocation.
202export async function destructiveGitDenial($: EngineInterface, rawCommand: string): Promise<string | null> {
203 const command = withoutHereDocBodies(rawCommand)
204 if (!destructiveMatch(command) && !gitInvocations(command).some(publicationVerb)) return null
205 const shell = await shellOf($, command)
206 return (await publicationDenial($, shell)) ?? (await dirtyCheckoutDenial($, shell))
207}
208
209async function agentWorktree($: EngineInterface, shell: Shell, root: string, project: string): Promise<boolean> {
210 const cut = Math.max(root.lastIndexOf('/'), root.lastIndexOf('\\'))
211 if (!root.slice(cut + 1).startsWith('agent-')) return false
212 const parents = [`${await sidequestHome($, shell.host)}/worktrees/${await slugify(project, shell.host.windows)}`, `${project}/.claude/worktrees`]
213 return parents.some((parent) => samePath(parent, root.slice(0, cut), shell.host))
214}
215
216// The board hands out the registered root and the agent worktrees; a clone an executor made itself under its
217// verification directory or scratchpad belongs to nobody else and stays writable (SQ-3436).
218async function protectedCheckout($: EngineInterface, shell: Shell, row: SnapshotRow, root: string, project: string): Promise<boolean> {
219 if (samePath(root, project, shell.host)) return true
220 if (!row.worktree || samePath(root, row.worktree, shell.host)) return false
221 return agentWorktree($, shell, root, project)
222}
223
224async function isolatedMutationDenial($: EngineInterface, shell: Shell, row: SnapshotRow, root: string, project: string): Promise<string | null> {
225 return (await protectedCheckout($, shell, row, root, project)) ? isolatedMutationRefusal(root) : null
226}
227
228function sharedTreeCommitDenial(row: SnapshotRow, subcommand: string, sharedCheckout: boolean): string | null {
229 return row.state === 'claimed' && subcommand === 'commit' && sharedCheckout ? sharedTreeCommitRefusal(row.ref) : null
230}
231
232async function mutationDenial($: EngineInterface, shell: Shell, row: SnapshotRow, project: string, invocation: GitInvocation): Promise<string | null> {
233 const { root } = await repoAt($, shell, invocation.index, invocation.dashC)
234 if (!row.sharedTree) return isolatedMutationDenial($, shell, row, root, project)
235 return sharedTreeCommitDenial(row, invocation.subcommand, samePath(root, project, shell.host))
236}
237
238// An isolated executor may not mutate a checkout the board handed to anyone else, and a shared-tree claim
239// commits only through the board, which stages its approved scope alone.
240export async function sharedCheckoutGitDenial($: EngineInterface, command: string, agentId: string | undefined): Promise<string | null> {
241 const mutations = gitInvocations(command).filter((invocation) => !isReadOnlyGit(invocation.subcommand, shellTokens(invocation.args)))
242 if (!mutations.length) return null
243 const row = await caller($, agentId)
244 if (!row) return null
245 const shell = await shellOf($, command)
246 const project = await projectRoot($, shell.cwd, shell.host)
247 for (const invocation of mutations) {
248 const denial = await mutationDenial($, shell, row, project, invocation)
249 if (denial) return denial
250 }
251 return null
252}
253hooks/guards/guard-errors.ts 10 lines1import type { EngineInterface } from 'claude-code'
2
3export function guardFailure($: EngineInterface, guard: string, tool: string, policy: 'closed' | 'open', error: unknown): string | null {
4 const reason = error instanceof Error ? error.message : String(error)
5 const message = `${guard} failed for ${tool}: ${reason}`
6 $.ui.log(message, { to: 'debug' })
7 $.ui.toast(message)
8 return policy === 'closed' ? `sidequest: ${message}; tool call refused` : null
9}
10hooks/guards/executor-admission.ts 189 lines1import type { EngineInterface, On, ToolCallResult } from 'claude-code'
2
3import { classify, isReadOnlyExecutor } from '../../src/lib/exec-names.js'
4import { mainThreadMutationDenial } from '../../src/hooks/shared/main-thread-mutations.js'
5import { namesShellWrite } from '../../src/hooks/shared/shell-writes.js'
6import { guardFailure } from './guard-errors.js'
7import { callerRow } from './context-guards.js'
8import type { Snapshot, SnapshotRow } from './context-guards.js'
9import { projectDirectory, readSnapshot } from './snapshot.js'
10
11// The executor admission decisions (hooks/force-exec-bypass.js) need the store, agentsync and git, which a
12// module cannot load. So the module answers every call the snapshot proves that code would pass, and runs it
13// as one process only for an Agent spawn or a call it could refuse. The snapshot holds one project's rows
14// while the decision reads every project's live store, so a missing row proves nothing.
15
16export const GUARDED_TOOLS = /^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit|SendMessage|mcp__plugin_sidequest_board__(dispatch|update|scopeRequest|remove|verdict))$/
17const SHELL_TOOLS = /^(Bash|PowerShell)$/
18const WRITE_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
19const CLI_DISPATCH = /\bsidequest(?:\.js)?["']?\s+dispatch\s+SQ-\d+\b/i
20const CURRENT_EXECUTOR_KINDS = new Set([
21 'claude_builtin', 'codex_dispatch', 'discovered_model', 'read_only_claude_builtin', 'read_only_codex_dispatch', 'read_only_discovered_model',
22])
23const RESERVED_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent'])
24const SNAPSHOT_TTL_MS = 2000
25// The store rewrites the snapshot on every dispatch change, but a failed side write leaves an old one in place.
26const SNAPSHOT_STALE_MS = 60_000
27
28// row is the caller's own live dispatch: a subagent's by agent id, a teammate's by the session that claimed it.
29export type Caller = { agentId: string; type: string; sessionId: string; row: SnapshotRow | null; fresh: boolean }
30export type Decision = { deny?: string; updatedInput?: Record<string, unknown>; context?: string }
31
32function isCurrentExecutor(type: string): boolean {
33 return CURRENT_EXECUTOR_KINDS.has(classify(type).kind)
34}
35
36// Only a fresh row binding this agent to a live dispatch proves the decision would not call it finished.
37export function finishedExecutorUnproven(caller: Caller): boolean {
38 if (!caller.agentId || !isCurrentExecutor(caller.type)) return false
39 return !(caller.fresh && caller.row?.sessionId === caller.sessionId)
40}
41
42// The decision's dispatch and finished-executor checks need an agent id; its read-only check needs only the type.
43export function shellNeedsDecision(caller: Caller, command: string): boolean {
44 if (isReadOnlyExecutor(caller.type) && namesShellWrite(command)) return true
45 return Boolean(caller.agentId) && (CLI_DISPATCH.test(command) || finishedExecutorUnproven(caller))
46}
47
48// A helper's write is checked against its owner's scope, which can sit in any project's live store.
49export function writeNeedsDecision(caller: Caller): boolean {
50 if (!caller.agentId) return false
51 return isCurrentExecutor(caller.type) ? finishedExecutorUnproven(caller) : true
52}
53
54function textField(e: object, name: string): string {
55 const value: unknown = Reflect.get(e, name)
56 return typeof value === 'string' ? value : ''
57}
58
59// A message always runs the decision: its late-steer check finds a terminal recipient in any project's store.
60export function needsDecision(tool: string, e: object, caller: Caller): boolean {
61 if (SHELL_TOOLS.test(tool)) return shellNeedsDecision(caller, textField(e, 'command'))
62 if (WRITE_TOOLS.test(tool)) return writeNeedsDecision(caller)
63 return tool === 'SendMessage' || Boolean(caller.agentId)
64}
65
66type OutputFields = { permissionDecision?: string; permissionDecisionReason?: string; updatedInput?: Record<string, unknown>; additionalContext?: string }
67
68// The decision process prints the PreToolUse command-hook output it always printed, or nothing to pass.
69export function parseDecision(stdout: string): Decision {
70 if (!stdout.trim()) return {}
71 const output: OutputFields = (JSON.parse(stdout) as { hookSpecificOutput?: OutputFields }).hookSpecificOutput ?? {}
72 if (output.permissionDecision === 'deny') return { deny: String(output.permissionDecisionReason ?? '') }
73 return { updatedInput: output.updatedInput, context: output.additionalContext }
74}
75
76// The rewrite replaces the arguments whole: the decision removes fields (isolation, model, mode) as well as setting them.
77export function rewrittenCall<E extends object>(e: E, updatedInput: Record<string, unknown>): E {
78 const rewritten = { ...e }
79 for (const key of Object.keys(rewritten)) {
80 if (!RESERVED_KEYS.has(key)) Reflect.deleteProperty(rewritten, key)
81 }
82 return Object.assign(rewritten, updatedInput)
83}
84
85const snapshots = new Map<string, { readAt: number; snapshot: Snapshot | null }>()
86
87async function cachedSnapshot($: EngineInterface, directory: string): Promise<Snapshot | null> {
88 const cached = snapshots.get(directory)
89 if (cached && Date.now() - cached.readAt < SNAPSHOT_TTL_MS) return cached.snapshot
90 const snapshot = await readSnapshot($, directory).catch(() => null)
91 snapshots.set(directory, { readAt: Date.now(), snapshot })
92 return snapshot
93}
94
95function isFresh(snapshot: Snapshot | null): boolean {
96 return snapshot !== null && Date.now() - Date.parse(snapshot.at) < SNAPSHOT_STALE_MS
97}
98
99async function agentType($: EngineInterface, agentId: string): Promise<string> {
100 return (await $.agent.list()).find((agent) => agent.id === agentId)?.type ?? ''
101}
102
103// A subagent's type is the runtime's; a teammate runs as its session's main loop, so its claimed row names it.
104async function callerOf($: EngineInterface, agentId: string): Promise<Caller> {
105 const sessionId = await $.session.id()
106 const snapshot = await cachedSnapshot($, await projectDirectory($, await $.session.cwd()))
107 const row = callerRow(snapshot?.dispatches ?? [], agentId || undefined, sessionId)
108 const type = agentId ? await agentType($, agentId) : row?.executor ?? ''
109 return { agentId, sessionId, row, fresh: isFresh(snapshot), type }
110}
111
112function toolArguments(e: object): Record<string, unknown> {
113 return Object.fromEntries(Object.entries(e).filter(([key]) => !RESERVED_KEYS.has(key)))
114}
115
116let transcriptPath = ''
117
118async function runDecision($: EngineInterface, tool: string, e: object, caller: Caller): Promise<Decision> {
119 const input = {
120 hook_event_name: 'PreToolUse', tool_name: tool, tool_input: toolArguments(e), session_id: caller.sessionId,
121 cwd: await $.session.cwd(), transcript_path: transcriptPath || undefined, agent_id: caller.agentId || undefined, agent_type: caller.type || undefined,
122 }
123 const ran = await $.process.run(['node', `${$.plugin.root}/hooks/force-exec-bypass.js`], { stdin: JSON.stringify(input), timeoutMs: 10_000 })
124 if (ran.exitCode !== 0) throw new Error(`force-exec-bypass exited ${ran.exitCode}: ${ran.stderr.slice(0, 200)}`)
125 return parseDecision(ran.stdout)
126}
127
128function withContext(ran: ToolCallResult, context: string | undefined): ToolCallResult {
129 if (!context || ran.deny !== undefined) return ran
130 return { ...ran, context: [...(ran.context ?? []), context] }
131}
132
133async function applied<E extends object>(e: E, next: (input: E) => Promise<ToolCallResult>, decision: Decision): Promise<ToolCallResult> {
134 if (decision.deny !== undefined) return { deny: decision.deny }
135 return withContext(await next(decision.updatedInput ? rewrittenCall(e, decision.updatedInput) : e), decision.context)
136}
137
138// A claimed teammate is no main thread either, though it calls without an agent id.
139async function callerDecision($: EngineInterface, e: { tool: string }, caller: Caller): Promise<Decision> {
140 const denial = caller.row ? mainThreadMutationDenial(e.tool, toolArguments(e)) : null
141 if (denial) return { deny: denial }
142 if (evidenceFileCall(e.tool, caller)) return evidenceCallerDecision($, e, caller)
143 return needsDecision(e.tool, e, caller) ? runDecision($, e.tool, e, caller) : {}
144}
145
146function evidenceFileCall(tool: string, caller: Caller): boolean {
147 return caller.row?.evidenceMode === true && /^(Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell)$/.test(tool);
148}
149
150async function evidenceCallerDecision($: EngineInterface, e: { tool: string }, caller: Caller): Promise<Decision> {
151 const scope = caller.fresh && caller.row?.state === 'claimed' ? caller.row : {};
152 const input = { tool_name: e.tool, tool_input: toolArguments(e), cwd: await $.session.cwd(), scope };
153 const ran = await $.process.run(['node', `${$.plugin.root}/hooks/guard-evidence-write.js`], { stdin: JSON.stringify(input), timeoutMs: 10_000 }).catch(() => ({ exitCode: 1, stdout: '' }));
154 if (ran.exitCode !== 0) return { deny: 'sidequest: evidence path guard unavailable; write refused.' };
155 return parseDecision(ran.stdout);
156}
157
158// The main-thread-only fields are refused from the call alone, so no engine failure can admit them.
159async function guardedDecision($: EngineInterface, e: { tool: string; agentId?: string }): Promise<Decision> {
160 const denial = e.agentId ? mainThreadMutationDenial(e.tool, toolArguments(e)) : null
161 if (denial) return { deny: denial }
162 try {
163 return await callerDecision($, e, await callerOf($, e.agentId ?? ''))
164 } catch (error) {
165 return { deny: guardFailure($, 'guard-executor-admission', e.tool, 'closed', error)! }
166 }
167}
168
169// A spawn admitted by accident skips routing and bypass-mode stamping, so this one fails closed.
170async function admissionDecision($: EngineInterface, e: { tool: string; agentId?: string }): Promise<Decision> {
171 try {
172 return await runDecision($, e.tool, e, await callerOf($, e.agentId ?? ''))
173 } catch (error) {
174 return { deny: guardFailure($, 'guard-executor-admission', e.tool, 'closed', error)! }
175 }
176}
177
178export function registerExecutorAdmission(on: On): void {
179 // tool.call carries no transcript_path; the helper evidence rule names the session transcript from here.
180 on('classic.UserPromptSubmit', ($, e, next) => {
181 transcriptPath = e.transcript_path
182 return next(e)
183 })
184
185 on('tool.call', { tool: 'Agent' }, async ($, e, next) => applied(e, next, await admissionDecision($, e)))
186
187 on('tool.call', { tool: GUARDED_TOOLS }, async ($, e, next) => applied(e, next, await guardedDecision($, e)))
188}
189hooks/guards/snapshot.ts 114 lines1import type { EngineInterface } from 'claude-code'
2
3import { callerRow } from './context-guards.js'
4import type { Snapshot, SnapshotRow } from './context-guards.js'
5import { resolveAbsolute } from './text-guards.js'
6import type { Machine } from './text-guards.js'
7
8// The module mirrors src/lib/store/paths.ts (homeRoot, nearestRepoRoot, slugify) to find the snapshot the
9// store writes, since it cannot load the store. A slug it gets wrong reads no rows and every guard stays silent.
10
11export async function machine($: EngineInterface): Promise<Machine> {
12 const windows = (await $.env.get('OS')) === 'Windows_NT'
13 const home = (windows ? await $.env.get('USERPROFILE') : undefined) ?? (await $.env.get('HOME')) ?? ''
14 return { home, windows }
15}
16
17async function readOrNull($: EngineInterface, file: string): Promise<string | null> {
18 try {
19 return await $.fs.read(file)
20 } catch {
21 return null
22 }
23}
24
25// A linked worktree always holds a .git file, and $.fs can only read files.
26export async function worktreeExists($: EngineInterface, worktree: string): Promise<boolean> {
27 return (await readOrNull($, `${worktree}/.git`)) !== null
28}
29
30function parentOf(directory: string): string {
31 const cut = Math.max(directory.lastIndexOf('/'), directory.lastIndexOf('\\'))
32 return cut > directory.search(/[\\/]/) ? directory.slice(0, cut) : directory.slice(0, cut + 1)
33}
34
35const LINKED_WORKTREE = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/]+\s*$/
36
37// A linked worktree's .git file names its main checkout, which is the board's project root.
38async function gitRootAt($: EngineInterface, directory: string): Promise<string | null> {
39 const gitFile = await readOrNull($, `${directory}/.git`)
40 if (gitFile !== null) return LINKED_WORKTREE.exec(gitFile.trim())?.[1] ?? directory
41 return (await readOrNull($, `${directory}/.git/HEAD`)) !== null ? directory : null
42}
43
44async function repoRoot($: EngineInterface, cwd: string): Promise<string> {
45 const marker = cwd.search(/[\\/]\.claude[\\/]worktrees[\\/]/)
46 if (marker >= 0) return cwd.slice(0, marker)
47 for (let directory = cwd; ; directory = parentOf(directory)) {
48 const root = await gitRootAt($, directory)
49 if (root !== null) return root
50 if (parentOf(directory) === directory) return cwd
51 }
52}
53
54export async function slugify(root: string, windows: boolean): Promise<string> {
55 const base = (root.split(/[\\/]/).filter(Boolean).at(-1) ?? '')
56 .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'project'
57 const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(windows ? root.toLowerCase() : root))
58 const hash = [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('').slice(0, 8)
59 return `${base}-${hash}`
60}
61
62export async function sidequestHome($: EngineInterface, host: Machine): Promise<string> {
63 const configured = (await $.env.get('SIDEQUEST_HOME'))?.trim()
64 if (configured) return resolveAbsolute(configured, host)
65 return resolveAbsolute(`${host.home}/.claude/sidequest`, host)
66}
67
68const projectDirectories = new Map<string, Promise<string>>()
69
70// The registered root a cwd belongs to: a linked worktree's main checkout, else the enclosing repo.
71export async function projectRoot($: EngineInterface, cwd: string, host: Machine): Promise<string> {
72 return resolveAbsolute(await repoRoot($, cwd), host)
73}
74
75async function locateProjectDirectory($: EngineInterface, cwd: string): Promise<string> {
76 const host = await machine($)
77 return `${await sidequestHome($, host)}/projects/${await slugify(await projectRoot($, cwd, host), host.windows)}`
78}
79
80// The store creates projects/<slug>/tickets when it registers a root, and the slug hashes that exact path,
81// so only the registered root itself has one, never a clone or a linked worktree of it.
82export async function registeredRoot($: EngineInterface, root: string, host: Machine): Promise<boolean> {
83 return $.fs.exists(`${await sidequestHome($, host)}/projects/${await slugify(root, host.windows)}/tickets`)
84}
85
86// The board folder of the session's project; cached per cwd because a session rarely moves.
87export function projectDirectory($: EngineInterface, cwd: string): Promise<string> {
88 let directory = projectDirectories.get(cwd)
89 if (!directory) {
90 directory = locateProjectDirectory($, cwd)
91 projectDirectories.set(cwd, directory)
92 }
93 return directory
94}
95
96export async function boardExists($: EngineInterface, directory: string): Promise<boolean> {
97 return (await readOrNull($, `${directory}/meta.json`)) !== null
98}
99
100export async function readSnapshot($: EngineInterface, directory: string): Promise<Snapshot | null> {
101 const text = await readOrNull($, `${directory}/dispatch-snapshot.json`)
102 if (text === null) return null
103 const parsed = JSON.parse(text) as { v?: number } & Snapshot
104 return parsed.v === 1 && Array.isArray(parsed.dispatches) ? parsed : null
105}
106
107export async function sessionRows($: EngineInterface): Promise<SnapshotRow[]> {
108 return (await readSnapshot($, await projectDirectory($, await $.session.cwd())))?.dispatches ?? []
109}
110
111export async function caller($: EngineInterface, agentId: string | undefined): Promise<SnapshotRow | null> {
112 return callerRow(await sessionRows($), agentId, await $.session.id())
113}
114hooks/guards/text-guards.ts 193 lines1import { hereDocAt, skipHereDocBodies, type HereDoc } from './heredocs.js';
2
3// Pure decisions for the shell guards: no Node, so the hooks module and the node tests share them.
4export type Machine = { home: string; windows: boolean };
5
6function deleteArguments(command: string): string[] {
7 const commands = /(?:^|[;&|{}()\n])\s*(?:[\w.-]+\s+)*(?:remove-item|rm|rmdir|rd|ri|del|erase)\b([^;&|{}\n]*)/gi;
8 return [...command.matchAll(commands)].map((match) => match[1] || '');
9}
10
11function normalizePath(value: string): string {
12 return value.toLowerCase().replace(/[\\/]+$/, '');
13}
14
15const LEADING_HOME_REFERENCE = /^(?:~|\$home|\$env:userprofile|%userprofile%)(?=[\\/]|$)/i;
16const HOME_REFERENCE = /\$home\b|\$env:userprofile\b|%userprofile%|(?<!\w)~(?=[\\/\s]|$)/i;
17const DRIVE = /^[A-Za-z]:/;
18
19function isAbsolute(target: string, machine: Machine): boolean {
20 return machine.windows ? /^(?:[A-Za-z]:)?[\\/]/.test(target) : target.startsWith('/');
21}
22
23function resolvedSegments(target: string, separator: string): string {
24 const parts: string[] = [];
25 for (const part of target.split(separator)) {
26 if (part === '..') parts.pop();
27 else if (part && part !== '.') parts.push(part);
28 }
29 return parts.join(separator);
30}
31
32// path.resolve for an absolute target, without Node: a drive-less Windows target lands on the home drive.
33export function resolveAbsolute(target: string, machine: Machine): string {
34 if (!machine.windows) return `/${resolvedSegments(target, '/')}`;
35 const unified = target.replace(/\//g, '\\');
36 const drive = DRIVE.exec(unified)?.[0] ?? machine.home.slice(0, 2);
37 return `${drive}\\${resolvedSegments(unified.replace(DRIVE, ''), '\\')}`;
38}
39
40function parentOf(resolved: string, separator: string): string {
41 const cut = resolved.lastIndexOf(separator);
42 return cut <= resolved.indexOf(separator) ? resolved.slice(0, cut + 1) : resolved.slice(0, cut);
43}
44
45function isProtectedPath(argumentsAfterDelete: string, machine: Machine): boolean {
46 const targets = argumentsAfterDelete.replace(/["']/g, '').split(/\s+/).map((target) => target.replace(LEADING_HOME_REFERENCE, () => machine.home));
47 // A home reference anywhere but the start of a target cannot be resolved here, so it still blocks.
48 if (HOME_REFERENCE.test(targets.join(' '))) return true;
49 const separator = machine.windows ? '\\' : '/';
50 const home = resolveAbsolute(machine.home, machine);
51 const root = resolveAbsolute(separator, { ...machine, home });
52 const protectedRoots = [home, `${home}${separator}.claude`, parentOf(home, separator), root].map(normalizePath);
53 return targets
54 .filter((target) => target !== '\\' && isAbsolute(target, machine))
55 .some((target) => {
56 const resolved = resolveAbsolute(target, { ...machine, home });
57 // Any drive root, not only the home drive's: resolveAbsolute ends in the separator only for a root.
58 if (resolved.endsWith(separator)) return true;
59 const normalized = normalizePath(resolved);
60 return protectedRoots.some((protectedRoot) => protectedRoot === normalized || protectedRoot.startsWith(`${normalized}${separator}`));
61 });
62}
63
64export function homeDeleteDenial(command: string, machine: Machine): string | null {
65 const recursive = /^(?:--recursive|-[a-z]*r[a-z]*|-recurse|\/s)$/i;
66 const blocked = deleteArguments(command).some((argumentsAfterDelete) => (
67 argumentsAfterDelete.replace(/["']/g, '').split(/\s+/).some((argument) => recursive.test(argument))
68 && isProtectedPath(argumentsAfterDelete, machine)
69 ));
70 return blocked ? 'sidequest: blocked a recursive delete aimed at the user profile or .claude root. Use a specific project or scratchpad path instead.' : null;
71}
72
73const COMMAND_SHIM = /\bstart-process\b[^\r\n;|]*\s-filepath\s+(?:"|')?(npm|npx|yarn|pnpm)(?:"|')?(?=\s|$)/i;
74
75export function powershellShimContext(command: string): string | null {
76 const shim = command.match(COMMAND_SHIM)?.[1]?.toLowerCase();
77 if (!shim) return null;
78 return `sidequest: ${shim} is a .cmd shim on Windows, so Start-Process needs the shim explicitly. Use \`Start-Process -FilePath "${shim}.cmd" -ArgumentList "run","dev"\` or \`Start-Process -FilePath "cmd" -ArgumentList "/c","${shim}","run","dev"\`.`;
79}
80
81type Quote = 'single' | 'double' | null;
82type Finding = { token: string; quoted: boolean };
83
84const WINDOWS_PATH = /^[A-Za-z]:\\[^\\\s"'`|&;(){}<>]+\\[^\s"'`|&;(){}<>]*/;
85const CONTAINER_PATH_FLAG = /(?:^|[;&|(]\s*)(?:docker\s+(?:exec|run)|kubectl\s+exec)\b[^\r\n;&|]*?(?:\s(?:-w|-v)\s+|\s--(?:workdir|volume|cwd)(?:=|\s+))(?:"|')?\/(?!\/)[^\s"'`|&;(){}<>]*/im;
86
87function escapedNewline(command: string, index: number): boolean {
88 let backslashes = 0;
89 for (let cursor = index - 1; command[cursor] === '\\'; cursor -= 1) backslashes += 1;
90 return backslashes % 2 === 1;
91}
92
93function commentStart(command: string, index: number): boolean {
94 if (command[index] !== '#') return false;
95 const previous = command[index - 1];
96 return previous === undefined || /[\s;&|(]/.test(previous);
97}
98
99// Double quotes keep a backslash literal except before $, `, ", \ or a newline,
100// so only those shapes actually reach the shell as a different path.
101function mangledInsideDoubleQuotes(command: string, token: string, index: number): boolean {
102 if (/\\\$/.test(token)) return true;
103 return token.endsWith('\\') && command[index + token.length] === '\n';
104}
105
106function windowsPath(command: string): Finding | null {
107 let quote: Quote = null;
108 let hereDocs: HereDoc[] = [];
109 const substitutions: Quote[] = [];
110
111 for (let index = 0; index < command.length; index += 1) {
112 const character = command[index];
113 if (quote === 'single') {
114 if (character === "'") quote = null;
115 continue;
116 }
117
118 if (character === '\n' && hereDocs.length > 0 && !escapedNewline(command, index)) {
119 index = skipHereDocBodies(command, index + 1, hereDocs) - 1;
120 hereDocs = [];
121 continue;
122 }
123
124 // A command substitution reopens command context, heredocs and all, even
125 // when it sits inside a double-quoted string.
126 if (character === '$' && command[index + 1] === '(') {
127 substitutions.push(quote);
128 quote = null;
129 index += 1;
130 continue;
131 }
132 if (character === ')' && substitutions.length > 0) {
133 quote = substitutions.pop() ?? null;
134 continue;
135 }
136
137 if (quote === 'double') {
138 const token = command.slice(index).match(WINDOWS_PATH)?.[0];
139 if (token && mangledInsideDoubleQuotes(command, token, index)) return { token, quoted: true };
140 if (character === '\\') {
141 index += 1;
142 } else if (character === '"') {
143 quote = null;
144 }
145 continue;
146 }
147
148 if (commentStart(command, index)) {
149 const lineEnd = command.indexOf('\n', index);
150 if (lineEnd < 0) break;
151 index = lineEnd - 1;
152 continue;
153 }
154
155 if (character === "'") {
156 quote = 'single';
157 continue;
158 }
159 if (character === '"') {
160 quote = 'double';
161 continue;
162 }
163 if (character === '<' && command[index - 1] !== '<' && command[index + 1] === '<') {
164 const parsed = hereDocAt(command, index);
165 if (parsed) {
166 hereDocs.push(parsed.hereDoc);
167 index = parsed.end - 1;
168 continue;
169 }
170 }
171
172 const token = command.slice(index).match(WINDOWS_PATH)?.[0];
173 if (token) return { token, quoted: false };
174 }
175
176 return null;
177}
178
179export type GuardVerdict = { deny: string } | { context: string } | null;
180
181export function bashWindowsPathVerdict(command: string): GuardVerdict {
182 if (CONTAINER_PATH_FLAG.test(command)) {
183 return { context: 'sidequest: Git Bash rewrites this POSIX container path before Docker or kubectl sees it. Use `MSYS_NO_PATHCONV=1 docker exec -w /app contractify-docai ...` or spell the container path as `docker exec -w //app contractify-docai ...`.' };
184 }
185 const found = windowsPath(command);
186 if (!found) return null;
187 return {
188 deny: found.quoted
189 ? `sidequest: double quotes eat the backslash in this Windows path (${found.token}) - a backslash before $ or a line break is not literal there; single-quote it or write it with forward slashes (C:/Users/...).`
190 : `sidequest: unquoted Windows path in a POSIX shell (${found.token}) - backslashes are eaten and the path collapses into a literal filename in cwd; quote the path or write it with forward slashes (C:/Users/...).`,
191 };
192}
193src/lib/denied-tools.ts 27 lines1// Dependency-free: the guard-denied-tools hook bundles it, and board config and category
2// validation share it so a pattern the board accepts is the pattern the hook matches.
3const BOARD_TOOL_PREFIX = 'mcp__plugin_sidequest_board';
4
5function deniedToolPattern(entry: unknown, label: string): string {
6 const pattern = String(entry ?? '').trim();
7 if (!/^[A-Za-z][\w.:-]*$/.test(pattern)) throw new Error(`${label} entries must be tool names or MCP prefixes: ${String(entry)}`);
8 if (pattern.startsWith(BOARD_TOOL_PREFIX)) throw new Error(`${label} cannot deny the Sidequest board tools executors need to claim and close: ${pattern}`);
9 return pattern;
10}
11
12export function normalizeDeniedTools(value: unknown, label = 'deniedTools'): string[] {
13 if (value == null) return [];
14 if (!Array.isArray(value)) throw new Error(`${label} must be an array of tool names.`);
15 return [...new Set(value.map((entry) => deniedToolPattern(entry, label)))];
16}
17
18// A bare MCP server prefix such as mcp__claude-in-chrome denies every tool on that server, the same
19// way the executor frontmatter's disallowedTools reads it.
20function toolDeniedBy(toolName: string, pattern: string): boolean {
21 return toolName === pattern || toolName.startsWith(`${pattern}__`);
22}
23
24export function deniedToolMatch(toolName: string, patterns: readonly string[]): string | null {
25 return patterns.find((pattern) => toolDeniedBy(toolName, pattern)) ?? null;
26}
27src/hooks/shared/read-only-git.ts 65 lines1const READ_ONLY_SUBCOMMANDS = new Set([
2 'status', 'log', 'show', 'diff', 'rev-parse', 'rev-list', 'ls-files', 'ls-tree', 'cat-file', 'describe', 'blame',
3 'merge-base', 'grep', 'for-each-ref', 'show-ref', 'shortlog', 'ls-remote', 'name-rev',
4 'count-objects', 'check-ignore', 'check-attr', 'diff-tree', 'diff-index',
5]);
6const GLOBAL_VALUE_FLAGS = new Set(['-C', '-c', '--config-env', '--git-dir', '--work-tree', '--namespace', '--super-prefix']);
7const GLOBAL_FLAGS = new Set(['--no-pager', '--paginate', '-P', '-p', '--no-optional-locks', '--literal-pathspecs', '--no-replace-objects']);
8const BRANCH_READ_FLAGS = /^(?:--list|--show-current|-l|-a|--all|-r|--remotes|-v|-vv|--verbose|--no-color|--color(?:=.*)?|--format=.*|--sort=.*|--contains(?:=.*)?|--no-contains(?:=.*)?|--merged(?:=.*)?|--no-merged(?:=.*)?|--points-at(?:=.*)?)$/;
9
10function branchReads(arguments_: string[]): boolean {
11 const flags = arguments_.filter((argument) => argument.startsWith('-'));
12 if (!flags.every((flag) => BRANCH_READ_FLAGS.test(flag))) return false;
13 return arguments_.every((argument) => argument.startsWith('-')) || arguments_.includes('--list');
14}
15
16const CONFIG_READ_ARGUMENTS = /^(?:(?:--global|--local|--system|--worktree) )?(?:(?:--get|--get-all) [^ -][^ ]*|(?:--list|-l))$/;
17const TAG_READ_FLAGS = /^(?:--list|-l|--contains(?:=.*)?|--points-at(?:=.*)?|--no-color|--color(?:=.*)?|--format=.*|--sort=.*|--ignore-case|-i|-n\d*)$/;
18
19function tagReads(arguments_: string[]): boolean {
20 const flags = arguments_.filter((argument) => argument.startsWith('-'));
21 return flags.some((flag) => /^(?:--list|-l|--contains|--points-at)(?:=|$)/.test(flag))
22 && flags.every((flag) => TAG_READ_FLAGS.test(flag));
23}
24
25function reflogReads(arguments_: string[]): boolean {
26 return !arguments_.some((argument) => ['expire', 'delete', 'drop', 'write'].includes(argument));
27}
28
29const READ_ARGUMENTS = new Map<string, (arguments_: string[]) => boolean>([
30 ['branch', branchReads],
31 ['worktree', (arguments_) => arguments_[0] === 'list'],
32 ['remote', (arguments_) => /^(?:-v$|(?:show|get-url)(?: |$))/.test(arguments_.join(' '))],
33 ['config', (arguments_) => CONFIG_READ_ARGUMENTS.test(arguments_.join(' '))],
34 ['tag', tagReads],
35 ['stash', (arguments_) => ['list', 'show'].includes(arguments_[0] ?? '')],
36 ['reflog', reflogReads],
37]);
38
39export function isReadOnlyGit(subcommand: string, arguments_: string[]): boolean {
40 if (arguments_.some((argument) => /^--output(?:=|$)/.test(argument))) return false;
41 if (READ_ONLY_SUBCOMMANDS.has(subcommand)) return true;
42 return READ_ARGUMENTS.get(subcommand)?.(arguments_) ?? false;
43}
44
45function globalFlagWidth(word: string): number {
46 if (GLOBAL_VALUE_FLAGS.has(word)) return 2;
47 if (GLOBAL_FLAGS.has(word)) return 1;
48 return /^(?:--(?:config-env|git-dir|work-tree|namespace|super-prefix)=|-C.|-c.)/.test(word) ? 1 : 0;
49}
50
51export function gitSubcommandIndex(words: string[]): number {
52 let index = 1;
53 while (words[index]?.startsWith('-')) {
54 const width = globalFlagWidth(words[index]!);
55 if (!width) return -1;
56 index += width;
57 }
58 return index < words.length ? index : -1;
59}
60
61export function gitWordsReadOnly(words: string[]): boolean {
62 const index = gitSubcommandIndex(words);
63 return index > 0 && isReadOnlyGit(words[index]!, words.slice(index + 1));
64}
65hooks/guards/git-command.ts 123 lines1import { resolveAbsolute, type Machine } from './text-guards.js';
2
3// The one shell-command reader every git guard shares: where each git invocation sits, which directory it
4// acts in, and which subcommand it runs. No Node, so the hooks module and its tests share it.
5
6const WORD = String.raw`(?:"[^"]+"|'[^']+'|\S+)`;
7const GIT = String.raw`git\s+(?:-C\s+${WORD}\s+)?`;
8const DESTRUCTIVE: Array<{ pattern: RegExp; label: string }> = [
9 { pattern: new RegExp(`${GIT}reset\\s+[^\\n;|&]*--hard`, 'i'), label: 'git reset --hard' },
10 { pattern: new RegExp(`${GIT}clean\\s+-[a-z]*f`, 'i'), label: 'git clean -f' },
11 { pattern: new RegExp(`${GIT}(?:checkout|restore)\\s+(?:[^\\n;|&]*\\s)?(?:--\\s+)?(?:\\.|:/)(?:\\s|$|;|&|\\|)`, 'i'), label: 'a whole-tree checkout/restore' },
12 { pattern: new RegExp(`${GIT}(?:checkout|switch)\\s+[^\\n;|&]*(?:--force|\\s-f)\\b`, 'i'), label: 'a forced checkout/switch' },
13];
14const INVOCATION = new RegExp(String.raw`(?:^\s*|[;&|\n]\s*|\$\(\s*)(?:&\s*)?git\s+((?:(?:-C|-c|--config-env|--git-dir|--work-tree|--namespace|--super-prefix)\s+${WORD}\s+|--?[^\s]+\s+)*)([a-z][a-z-]*)\b((?:"[^"\n]*"|'[^'\n]*'|[^\n;|&])*)`, 'gi');
15// Case-sensitive: `-c name=value` sets config, only `-C` names a directory.
16const DASH_C = new RegExp(String.raw`(?:^|\s)-C\s+(${WORD})`, 'g');
17const CD = /(?:^|[\n;&|])\s*cd\s+((?:"[^"\n]*"|'[^'\n]*'|[^\n;&|])*)/gi;
18
19export type GitInvocation = { index: number; dashC: string | null; subcommand: string; args: string };
20export type DestructiveMatch = { label: string; index: number; dashC: string | null };
21
22export function unquote(value: string): string {
23 return value.replace(/^["']|["']$/g, '');
24}
25
26export function shellTokens(value: string): string[] {
27 return (value.match(/"[^"]*"|'[^']*'|\S+/g) || []).map(unquote);
28}
29
30const BALANCED_QUOTES = /^(?:[^"']|"[^"]*"|'[^']*')*$/;
31
32// A backslash escapes the next character inside quotes or out, so escaped pairs drop before the quotes pair up.
33export function quotedAt(command: string, index: number): boolean {
34 return !BALANCED_QUOTES.test(command.slice(0, index).replace(/\\[\s\S]/g, ''));
35}
36
37function lastDashC(text: string): string | null {
38 const found = [...text.matchAll(DASH_C)].at(-1)?.[1];
39 return found ?? null;
40}
41
42function parsedInvocation(match: RegExpMatchArray): GitInvocation {
43 if (!BALANCED_QUOTES.test(match[0])) throw new Error(`could not parse command segment: ${match[0].trim()}`);
44 return { index: match.index!, dashC: lastDashC(match[1] ?? ''), subcommand: (match[2] ?? '').toLowerCase(), args: match[3] ?? '' };
45}
46
47export function gitInvocations(command: string): GitInvocation[] {
48 return [...command.matchAll(INVOCATION)]
49 .filter((match) => !quotedAt(command, match.index))
50 .map(parsedInvocation);
51}
52
53function destructiveInvocation(invocation: GitInvocation): DestructiveMatch[] {
54 const text = `git ${invocation.subcommand} ${invocation.args}`;
55 const found = DESTRUCTIVE.find(({ pattern }) => pattern.test(text));
56 return found ? [{ label: found.label, index: invocation.index, dashC: invocation.dashC }] : [];
57}
58
59export function destructiveMatches(command: string): DestructiveMatch[] {
60 return gitInvocations(command).flatMap(destructiveInvocation);
61}
62
63export function destructiveMatch(command: string): DestructiveMatch | null {
64 return destructiveMatches(command)[0] ?? null;
65}
66
67// Git Bash spells C:\x as /c/x; only Windows needs the drive put back.
68function gitBashPath(value: string, machine: Machine): string {
69 const drive = machine.windows ? /^\/([a-zA-Z])(?=\/|$)/.exec(value) : null;
70 return drive ? `${drive[1]}:${value.slice(2)}` : value;
71}
72
73export function shellPath(cwd: string, value: string, machine: Machine): string {
74 const target = gitBashPath(value, machine);
75 const absolute = machine.windows ? /^(?:[A-Za-z]:)?[\\/]/.test(target) : target.startsWith('/');
76 return resolveAbsolute(absolute ? target : `${gitBashPath(cwd, machine)}/${target}`, machine);
77}
78
79function directoryChangeTarget(segment: string): string {
80 const tokens = shellTokens(segment.trim());
81 if (tokens[0] === '--') tokens.shift();
82 if (tokens.length !== 1 || /[$`()]|^\s*["']?~/.test(segment) || !BALANCED_QUOTES.test(segment)) {
83 throw new Error(`could not parse command segment: cd ${segment.trim()}`);
84 }
85 return tokens[0]!;
86}
87
88// Each relative cd starts from the preceding directory; -C starts from the final shell directory.
89export function invocationDirectory(command: string, index: number, dashC: string | null, cwd: string, machine: Machine): string {
90 let base = shellPath(cwd, '.', machine);
91 for (const change of command.slice(0, index).matchAll(CD)) {
92 if (!quotedAt(command, change.index)) base = shellPath(base, directoryChangeTarget(change[1] ?? ''), machine);
93 }
94 return dashC ? shellPath(base, directoryChangeTarget(dashC), machine) : base;
95}
96
97export function samePath(left: string, right: string, machine: Machine): boolean {
98 const normalize = (value: string) => {
99 const resolved = resolveAbsolute(gitBashPath(value, machine), machine);
100 return machine.windows ? resolved.toLowerCase() : resolved;
101 };
102 return normalize(left) === normalize(right);
103}
104
105export function tagAction(args: string): boolean {
106 const tokens = shellTokens(args);
107 if (tokens.some((token) => /^(?:-a|--annotate|-s|--sign|-u|--local-user|-d|--delete|-f|--force)$/.test(token))) return true;
108 const lists = tokens.some((token) => /^(?:-l|--list|-n\d*|--contains|--no-contains|--points-at|--merged|--no-merged|--column|--sort|--format)$/.test(token));
109 return !lists && tokens.some((token) => !token.startsWith('-'));
110}
111
112export function isTagRef(value: string): boolean {
113 return value.replace(/^\+/, '').startsWith('refs/tags/') || value === '--tags' || value === '--follow-tags';
114}
115
116export function branchName(value: string): string {
117 return value.replace(/^\+/, '').replace(/^refs\/heads\//, '').replace(/^\/+/, '');
118}
119
120export function pushesEverything(tokens: readonly string[]): boolean {
121 return tokens.some((token) => token === '--tags' || token === '--follow-tags' || token === '--all' || token === '--mirror');
122}
123hooks/guards/heredocs.ts 91 lines1export type HereDoc = {
2 delimiter: string;
3 stripTabs: boolean;
4};
5
6export function hereDocAt(command: string, index: number): { hereDoc: HereDoc; end: number } | null {
7 let cursor = index + 2;
8 const stripTabs = command[cursor] === '-';
9 if (stripTabs) cursor += 1;
10 while (command[cursor] === ' ' || command[cursor] === '\t') cursor += 1;
11
12 const quote = command[cursor];
13 if (quote === "'" || quote === '"') {
14 const end = command.indexOf(quote, cursor + 1);
15 if (end < 0) return null;
16 return { hereDoc: { delimiter: command.slice(cursor + 1, end), stripTabs }, end: end + 1 };
17 }
18
19 const match = command.slice(cursor).match(/^[^\s|&;()<>]+/);
20 if (!match) return null;
21 return { hereDoc: { delimiter: match[0], stripTabs }, end: cursor + match[0].length };
22}
23
24export function skipHereDocBodies(command: string, index: number, hereDocs: HereDoc[]): number {
25 let cursor = index;
26 for (const { delimiter, stripTabs } of hereDocs) {
27 while (cursor < command.length) {
28 const lineEnd = command.indexOf('\n', cursor);
29 const end = lineEnd < 0 ? command.length : lineEnd;
30 let line = command.slice(cursor, end).replace(/\r$/, '');
31 if (stripTabs) line = line.replace(/^\t+/, '');
32 cursor = lineEnd < 0 ? command.length : lineEnd + 1;
33 if (line === delimiter) break;
34 }
35 }
36 return cursor;
37}
38
39function escapedNewline(command: string, index: number): boolean {
40 let backslashes = 0;
41 for (let cursor = index - 1; command[cursor] === '\\'; cursor -= 1) backslashes += 1;
42 return backslashes % 2 === 1;
43}
44
45function commentStart(command: string, index: number): boolean {
46 if (command[index] !== '#') return false;
47 const previous = command[index - 1];
48 return previous === undefined || /[\s;&|(]/.test(previous);
49}
50
51export function withoutHereDocBodies(command: string): string {
52 let quote = '';
53 let hereDocs: HereDoc[] = [];
54 let result = '';
55 let copiedThrough = 0;
56
57 for (let index = 0; index < command.length; index += 1) {
58 const character = command[index];
59 if (quote) {
60 if (character === '\\') index += 1;
61 else if (character === quote) quote = '';
62 continue;
63 }
64 if (character === '\n' && hereDocs.length > 0 && !escapedNewline(command, index)) {
65 result += command.slice(copiedThrough, index + 1);
66 index = skipHereDocBodies(command, index + 1, hereDocs) - 1;
67 copiedThrough = index + 1;
68 hereDocs = [];
69 continue;
70 }
71 if (commentStart(command, index)) {
72 const lineEnd = command.indexOf('\n', index);
73 if (lineEnd < 0) break;
74 index = lineEnd - 1;
75 continue;
76 }
77 if (character === "'" || character === '"') {
78 quote = character;
79 continue;
80 }
81 if (character === '<' && command[index - 1] !== '<' && command[index + 1] === '<') {
82 const parsed = hereDocAt(command, index);
83 if (parsed) {
84 hereDocs.push(parsed.hereDoc);
85 index = parsed.end - 1;
86 }
87 }
88 }
89 return result + command.slice(copiedThrough);
90}
91src/lib/exec-names.ts 244 lines1export const EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max'] as const);
2export type Effort = (typeof EFFORTS)[number];
3
4export const CLAUDE_PREFIX = 'sidequest-exec-';
5export const DISPATCH_PREFIX = 'sidequest-exec-dispatch-';
6export const EVIDENCE_CLAUDE_PREFIX = 'sidequest-exec-evidence-';
7export const EVIDENCE_DISPATCH_NAME = 'sidequest-exec-dispatch-evidence';
8export const READ_ONLY_CLAUDE_PREFIX = 'sidequest-exec-readonly-';
9export const READ_ONLY_DISPATCH_PREFIX = 'sidequest-exec-dispatch-readonly-';
10export const DISCOVERED_MODEL_PREFIX = 'sidequest-exec-model-';
11export const READ_ONLY_DISCOVERED_MODEL_PREFIX = 'sidequest-exec-readonly-model-';
12export const TICKET_PREFIX = 'sidequest-sq-';
13export const LEGACY_TICKET_PREFIX = 'sidequest-ticket-';
14export const DIAGNOSTIC_PROBE_NAME = 'sidequest-diagnostic-probe';
15
16export type ExecutorKind = 'codex_dispatch' | 'claude_builtin' | 'discovered_model' | 'read_only_codex_dispatch' | 'read_only_claude_builtin' | 'read_only_discovered_model' | 'ticket' | 'legacy_ticket' | 'unknown';
17export interface ExecutorClassification {
18 kind: ExecutorKind;
19 effort: Effort | null;
20}
21
22export function isEffort(value: unknown): value is Effort {
23 return typeof value === 'string' && (EFFORTS as readonly string[]).includes(value);
24}
25
26// Claude Code validates the Agent `name` parameter against
27// /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/, so 64 is a hard ceiling. The launch names
28// below stay far shorter: the native agent list truncates, and a name only earns
29// its place there by being readable at a glance.
30export const AGENT_NAME_MAX_LENGTH = 64;
31const LAUNCH_SLUG_MAX_WORDS = 3;
32const LAUNCH_SLUG_MAX_LENGTH = 24;
33const ROUTE_MODEL_TOKEN_MAX_LENGTH = 24;
34
35// Filler words spend the slug budget without distinguishing one ticket from
36// another, so they are dropped before the budget is counted.
37const LAUNCH_SLUG_FILLER = new Set([
38 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', 'in', 'into', 'is', 'it',
39 'its', 'of', 'on', 'or', 'over', 'per', 'that', 'the', 'their', 'then', 'this', 'to', 'under',
40 'via', 'when', 'while', 'with', 'without',
41]);
42
43function slugTokens(value: unknown): string[] {
44 return String(value == null ? '' : value)
45 .normalize('NFKD')
46 .toLowerCase()
47 .replace(/[^a-z0-9]+/g, ' ')
48 .trim()
49 .split(' ')
50 .filter(Boolean);
51}
52
53export function refSlug(ref: unknown): string {
54 return slugTokens(ref).join('-');
55}
56
57export function titleSlug(title: unknown): string {
58 const tokens = slugTokens(title);
59 const meaningful = tokens.filter((token) => !LAUNCH_SLUG_FILLER.has(token));
60 const chosen = (meaningful.length ? meaningful : tokens).slice(0, LAUNCH_SLUG_MAX_WORDS);
61 let slug = '';
62 for (const token of chosen) {
63 const next = slug ? `${slug}-${token}` : token;
64 if (next.length > LAUNCH_SLUG_MAX_LENGTH) break;
65 slug = next;
66 }
67 if (!slug && chosen.length) slug = String(chosen[0]).slice(0, LAUNCH_SLUG_MAX_LENGTH);
68 return slug;
69}
70
71function routeModelToken(resolvedExec: unknown): string {
72 if (!resolvedExec || typeof resolvedExec !== 'object') return '';
73 const exec = resolvedExec as Record<string, unknown>;
74 const isClaude = exec.backend === 'claude';
75 const value = isClaude
76 ? String(exec.runsModel || exec.dispatchModel || '')
77 : String(exec.runsLabel || exec.dispatchModel || exec.runsModel || '');
78 const tokens = slugTokens(value).filter((token) => !isClaude || token !== 'claude');
79 const token = isClaude ? tokens.join('-') : tokens.at(-1) || '';
80 return token.slice(0, ROUTE_MODEL_TOKEN_MAX_LENGTH);
81}
82
83/**
84 * The name a Sidequest launch carries in Claude Code's native agent list.
85 * Deterministic from board state alone: ticket ref, title, resolved execution
86 * route, and dispatch sequence. Sequence 1 is unsuffixed; a redispatch counts up
87 * (`sq-843-release-engine-terra-high-2`) so it never shadows a live sibling.
88 */
89export function dispatchLaunchName(ref: unknown, title?: unknown, resolvedExec?: unknown, effort?: unknown, sequence?: unknown): string {
90 const base = refSlug(ref) || 'sidequest';
91 const model = routeModelToken(resolvedExec);
92 const routeEffort = isEffort(effort) ? effort : '';
93 const route = [model, routeEffort].filter(Boolean);
94 const seq = Number(sequence);
95 const suffix = Number.isInteger(seq) && seq > 1 ? `-${seq}` : '';
96 const fixedName = [base, ...route].join('-') + suffix;
97 const availableTitleLength = AGENT_NAME_MAX_LENGTH - fixedName.length - 1;
98 const slug = titleSlug(title).slice(0, Math.max(availableTitleLength, 0)).replace(/-+$/, '');
99 return slug ? [base, slug, ...route].join('-') + suffix : fixedName;
100}
101
102// Codex dispatch executors are effort-collapsed: the gateway resolves BOTH model and
103// effort from the [sidequest-route model=... effort=...] marker in the briefing and
104// overwrites the request's output_config.effort, so a per-effort definition ladder on
105// this side carried five copies of dead frontmatter (verified on the wire 2026-08-02:
106// a def pinned to effort low produced effort=xhigh when the marker said so). Claude
107// executors keep the per-effort ladder because the Agent tool has no effort parameter,
108// leaving frontmatter as the only carrier.
109export const DISPATCH_NAME = 'sidequest-exec-dispatch';
110export const READ_ONLY_DISPATCH_NAME = 'sidequest-exec-dispatch-readonly';
111
112export function stableClaudeName(effort: Effort): string {
113 return `${CLAUDE_PREFIX}${effort}`;
114}
115
116export function stableDispatchName(_effort?: Effort): string {
117 return DISPATCH_NAME;
118}
119
120export function stableEvidenceClaudeName(effort: Effort): string {
121 return `${EVIDENCE_CLAUDE_PREFIX}${effort}`;
122}
123
124export function isEvidenceExecutor(value: unknown): boolean {
125 if (typeof value !== 'string') return false;
126 const name = canonicalExecutorName(value);
127 return name === EVIDENCE_DISPATCH_NAME || EFFORTS.some((effort) => name === stableEvidenceClaudeName(effort));
128}
129
130export function stableReadOnlyClaudeName(effort: Effort): string {
131 return `${READ_ONLY_CLAUDE_PREFIX}${effort}`;
132}
133
134export function stableReadOnlyDispatchName(_effort?: Effort): string {
135 return READ_ONLY_DISPATCH_NAME;
136}
137
138// A discovered model the gateway shim does not serve runs from a user-scope definition whose frontmatter
139// pins its full id, because the Agent tool's `model` parameter only accepts the four Claude aliases
140// (GH-361). Effort rides the same frontmatter, so that ladder is per model and per effort.
141export function discoveredModelExecutorName(agentSlug: string, effort: Effort): string {
142 return `${DISCOVERED_MODEL_PREFIX}${agentSlug}-${effort}`;
143}
144
145export function readOnlyDiscoveredModelExecutorName(agentSlug: string, effort: Effort): string {
146 return `${READ_ONLY_DISCOVERED_MODEL_PREFIX}${agentSlug}-${effort}`;
147}
148
149const DISCOVERED_MODEL_SUFFIX_RE = /^[a-z0-9][a-z0-9-]*-(low|medium|high|xhigh|max)$/;
150
151function discoveredModelEffort(name: string, prefix: string): Effort | null {
152 const effort = DISCOVERED_MODEL_SUFFIX_RE.exec(name.slice(prefix.length))?.[1];
153 return name.startsWith(prefix) && isEffort(effort) ? effort : null;
154}
155
156function classifyEvidenceExecutor(name: string): ExecutorClassification | null {
157 if (name === EVIDENCE_DISPATCH_NAME) return { kind: 'read_only_codex_dispatch', effort: null };
158 const effort = name.slice(EVIDENCE_CLAUDE_PREFIX.length);
159 return name.startsWith(EVIDENCE_CLAUDE_PREFIX) && isEffort(effort) ? { kind: 'read_only_claude_builtin', effort } : null;
160}
161
162function classifyDiscoveredModel(name: string): ExecutorClassification | null {
163 const evidence = classifyEvidenceExecutor(name);
164 if (evidence) return evidence;
165 const readOnlyEffort = discoveredModelEffort(name, READ_ONLY_DISCOVERED_MODEL_PREFIX);
166 if (readOnlyEffort) return { kind: 'read_only_discovered_model', effort: readOnlyEffort };
167 const effort = discoveredModelEffort(name, DISCOVERED_MODEL_PREFIX);
168 return effort ? { kind: 'discovered_model', effort } : null;
169}
170
171const BUNDLED_AGENT_NAMES = new Set([
172 DISPATCH_NAME,
173 READ_ONLY_DISPATCH_NAME,
174 EVIDENCE_DISPATCH_NAME,
175 ...EFFORTS.map(stableEvidenceClaudeName),
176 DIAGNOSTIC_PROBE_NAME,
177 ...EFFORTS.map(stableClaudeName),
178 ...EFFORTS.map(stableReadOnlyClaudeName),
179]);
180const PLUGIN_NAMESPACE = 'sidequest:';
181
182export function canonicalExecutorName(name: string): string {
183 if (!name.startsWith(PLUGIN_NAMESPACE)) return name;
184 const unqualifiedName = name.slice(PLUGIN_NAMESPACE.length);
185 return BUNDLED_AGENT_NAMES.has(unqualifiedName) ? unqualifiedName : name;
186}
187
188export function bundledAgentType(name: string): string {
189 const canonicalName = canonicalExecutorName(name);
190 return BUNDLED_AGENT_NAMES.has(canonicalName) ? `${PLUGIN_NAMESPACE}${canonicalName}` : name;
191}
192
193export function isReadOnlyExecutor(name: unknown): boolean {
194 const kind = classify(name).kind;
195 return kind === 'read_only_codex_dispatch' || kind === 'read_only_claude_builtin' || kind === 'read_only_discovered_model';
196}
197
198export function isDiscoveredModelExecutor(name: unknown): boolean {
199 const kind = classify(name).kind;
200 return kind === 'discovered_model' || kind === 'read_only_discovered_model';
201}
202
203export function classify(value: unknown): ExecutorClassification {
204 if (typeof value !== 'string' || !value) return { kind: 'unknown', effort: null };
205 const name = canonicalExecutorName(value);
206
207 // The collapsed names carry no effort; the dispatch marker does. Checked before the
208 // prefix rules because READ_ONLY_DISPATCH_NAME is a proper prefix-collision with
209 // DISPATCH_PREFIX ('...dispatch-readonly' starts with '...dispatch-').
210 if (name === READ_ONLY_DISPATCH_NAME) return { kind: 'read_only_codex_dispatch', effort: null };
211 if (name === DISPATCH_NAME) return { kind: 'codex_dispatch', effort: null };
212 if (name === DIAGNOSTIC_PROBE_NAME) return { kind: 'unknown', effort: null };
213
214 // Both discovered-model prefixes extend a Claude ladder prefix, so they are matched first.
215 const discoveredModel = classifyDiscoveredModel(name);
216 if (discoveredModel) return discoveredModel;
217
218 // Pre-collapse records still name per-effort executors; classifying them keeps old
219 // dispatch records readable so the board can heal by redispatch instead of erroring.
220 if (name.startsWith(READ_ONLY_DISPATCH_PREFIX)) {
221 const effort = name.slice(READ_ONLY_DISPATCH_PREFIX.length);
222 if (isEffort(effort)) return { kind: 'read_only_codex_dispatch', effort };
223 return { kind: 'ticket', effort: null };
224 }
225 if (name.startsWith(READ_ONLY_CLAUDE_PREFIX)) {
226 const effort = name.slice(READ_ONLY_CLAUDE_PREFIX.length);
227 if (isEffort(effort)) return { kind: 'read_only_claude_builtin', effort };
228 return { kind: 'ticket', effort: null };
229 }
230 if (name.startsWith(DISPATCH_PREFIX)) {
231 const effort = name.slice(DISPATCH_PREFIX.length);
232 if (isEffort(effort)) return { kind: 'codex_dispatch', effort };
233 return { kind: 'ticket', effort: null };
234 }
235 if (name.startsWith(CLAUDE_PREFIX)) {
236 const effort = name.slice(CLAUDE_PREFIX.length);
237 if (isEffort(effort)) return { kind: 'claude_builtin', effort };
238 return { kind: 'ticket', effort: null };
239 }
240 if (name.startsWith(TICKET_PREFIX)) return { kind: 'ticket', effort: null };
241 if (name.startsWith(LEGACY_TICKET_PREFIX)) return { kind: 'legacy_ticket', effort: null };
242 return { kind: 'unknown', effort: null };
243}
244