SLOPSHOPPER

sidequest

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

guardtoastpromptprocess
★ 283v6.6.1MITupdated 2026-10-26Eigenwise/eigenwise-toolshed/plugins/sidequest
A shopper browsing a rack in a slop shop
README

Sidequest

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

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.

Start with Claude

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.

Keep tasks through context compaction

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 board every day

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.

If something stops working

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.

Support

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.

License

MIT (c) Eigenwise

Source 15 files
hooks/guards/register.ts 212 lines
1import 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}
212
hooks/guards/context-guards.ts 196 lines
1import { 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}
196
hooks/guards/git-guards.ts 253 lines
1import { 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}
253
hooks/guards/guard-errors.ts 10 lines
1import 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}
10
hooks/guards/executor-admission.ts 189 lines
1import 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}
189
hooks/guards/snapshot.ts 114 lines
1import 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}
114
hooks/guards/text-guards.ts 193 lines
1import { 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}
193
src/lib/denied-tools.ts 27 lines
1// 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}
27
src/hooks/shared/read-only-git.ts 65 lines
1const 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}
65
hooks/guards/git-command.ts 123 lines
1import { 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}
123
hooks/guards/heredocs.ts 91 lines
1export 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}
91
src/lib/exec-names.ts 244 lines
1export 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