The Loadout's core work board and orchestration loop for Claude Code. Tickets are classified into categories that route each one to a concrete model and…

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.
Setup guide · Generated reference · Loadout marketplace
Install Sidequest at project scope:
/plugin marketplace add poindexter12/loadout
/plugin install sidequest@loadout --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.
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.
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.
Use the dashboard to switch projects, scan todo, doing, and done work, search and filter tickets, and open full ticket details. You can edit tickets, add comments, connect related work, and set reminders from the board.
Sidequest also works through natural-language requests:
Show me the Sidequest backlog for this project.What is ready to dispatch for the checkout story?Add a ticket for the empty-state bug with the reproduction steps.What is blocking the checkout ticket?Review and integrate the checkout ticket if verification passed.Tell Claude the symptom:
The Sidequest board will not open. Diagnose it.
The checkout ticket will not dispatch. Explain what is blocking it.
A submitted ticket is waiting. Check it and finish the integration if it is safe.
Claude checks the local plugin connection, ticket state, dependencies, configured route, and delivery status, then gives you the next action. After an install or upgrade, start a new Claude Code session or reload plugins so the session picks up the current Sidequest connection and its bundled executors.
For worktree storage, recovery, and command details, see the generated reference. Ask Claude to inspect or clean up local worktrees rather than guessing at lifecycle commands.
MIT (c) Eigenwise
hooks/fn/compaction.js 303 lines1// src/hooks/shared/live-refs.ts
2var COMPACTION_RECOVERY_MARKER = "sidequest compaction recovery v1";
3var COMPACTION_GUARD_DEFAULT = "off";
4function compactionGuardMode(value) {
5 const mode = String(value ?? "").trim().toLowerCase();
6 return mode === "auto" || mode === "on" || mode === "off" ? mode : COMPACTION_GUARD_DEFAULT;
7}
8var LIVE_REFS_VERSION = 1;
9function trimSlashes(value) {
10 return value.replace(/[\\/]+$/, "");
11}
12function sidequestHome(sidequestHomeEnv, homeEnv, configDirEnv) {
13 const explicit = String(sidequestHomeEnv || "").trim();
14 if (explicit) return trimSlashes(explicit);
15 const configDir = trimSlashes(String(configDirEnv || "").trim());
16 if (configDir) return `${configDir}/sidequest`;
17 const home = trimSlashes(String(homeEnv || "").trim());
18 return home ? `${home}/.claude/sidequest` : "";
19}
20function liveRefsPath(home, sessionId) {
21 return `${trimSlashes(home)}/live-refs/${encodeURIComponent(sessionId)}.json`;
22}
23function parseLiveRefs(text) {
24 try {
25 const parsed = JSON.parse(text);
26 if (!parsed || parsed.version !== LIVE_REFS_VERSION || !Array.isArray(parsed.closed)) return null;
27 return {
28 version: LIVE_REFS_VERSION,
29 session: typeof parsed.session === "string" ? parsed.session : "",
30 at: typeof parsed.at === "string" ? parsed.at : "",
31 pin: typeof parsed.pin === "string" ? parsed.pin : "",
32 closed: parsed.closed.filter((ref) => typeof ref === "string" && ref.length > 0)
33 };
34 } catch (_) {
35 return null;
36 }
37}
38
39// src/hooks/fn/compaction.ts
40var PER_RESULT_CAP_BYTES = 32 * 1024;
41var TOTAL_CAP_BYTES = 64 * 1024;
42var DISPATCH_TOOL = "mcp__plugin_sidequest_board__dispatch";
43var BRIEFING = /sidequest-launcher\.js["']?\s+briefing\s+([A-Za-z][A-Za-z0-9]*-\d+)/;
44var REF = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
45var JEV_PLUGIN = "fast-jev-compaction";
46var encoder = new TextEncoder();
47var utf8Bytes = (text) => encoder.encode(text).length;
48function protectedCalls(messages, closedRefs = /* @__PURE__ */ new Set()) {
49 const calls = [];
50 for (const message of messages) {
51 if (message.role !== "assistant") continue;
52 for (const use of message.toolUses || []) {
53 const input = use.input || {};
54 let call = null;
55 if (use.tool === DISPATCH_TOOL) {
56 const ref = typeof input.ref === "string" && REF.test(input.ref) ? input.ref : "";
57 call = { id: use.tool_use_id, kind: "dispatch", ref };
58 } else if (use.tool === "Bash" && typeof input.command === "string") {
59 const match = BRIEFING.exec(input.command);
60 if (match) call = { id: use.tool_use_id, kind: "briefing", ref: match[1] || "", command: input.command };
61 } else if ((use.tool === "Agent" || use.tool === "Task") && typeof input.prompt === "string") {
62 const match = BRIEFING.exec(input.prompt);
63 if (match) call = { id: use.tool_use_id, kind: "agent", ref: match[1] || "" };
64 }
65 if (call && !(call.ref && closedRefs.has(call.ref))) calls.push(call);
66 }
67 }
68 return calls;
69}
70function blockIds(message) {
71 return [
72 ...(message.toolUses || []).map((use) => use.tool_use_id),
73 ...(message.toolResults || []).map((result) => result.tool_use_id)
74 ];
75}
76function retrieval(call) {
77 if (call.kind === "briefing" && call.command) {
78 const command = call.command.length > 400 ? `${call.command.slice(0, 400)}...` : call.command;
79 return `re-run ${command}`;
80 }
81 if (call.kind === "dispatch" && call.ref) return `${DISPATCH_TOOL} for ${call.ref} returns the spawn again`;
82 if (call.ref) return `mcp__plugin_sidequest_board__pulse and comments for ${call.ref}`;
83 return "the sidequest board tools";
84}
85function pointerText(call, bytes, reason) {
86 const what = `${call.kind} result${call.ref ? ` for ${call.ref}` : ""}`;
87 return `[${COMPACTION_RECOVERY_MARKER}: the ${what} (${bytes} B) was not restored after compaction, ${reason}. Retrieve: ${retrieval(call)}.]`;
88}
89function nonTextResult(result) {
90 const record = result.result;
91 const blocks = Array.isArray(record) ? record : record && typeof record === "object" && Array.isArray(record.content) ? record.content : null;
92 if (!blocks) return false;
93 return blocks.some((block) => Boolean(block) && typeof block === "object" && typeof block.type === "string" && block.type !== "text");
94}
95var freshUse = (use) => ({ tool_use_id: use.tool_use_id, tool: use.tool, input: use.input });
96var freshResult = (result) => ({ tool_use_id: result.tool_use_id, text: result.text, isError: result.isError });
97function restoreProtected(input, output, protectedIds, cap = { perResult: PER_RESULT_CAP_BYTES, total: TOTAL_CAP_BYTES }) {
98 const calls = /* @__PURE__ */ new Map();
99 for (const entry of protectedIds) {
100 const call = typeof entry === "string" ? { id: entry, kind: "dispatch", ref: "" } : entry;
101 calls.set(call.id, call);
102 }
103 const messages = [...output];
104 const report = { messages, restored: [], pointed: [], skipped: [] };
105 if (!calls.size) return report;
106 const useAt = /* @__PURE__ */ new Map();
107 const resultAt = /* @__PURE__ */ new Map();
108 input.forEach((message, index) => {
109 for (const use of message.toolUses || []) useAt.set(use.tool_use_id, index);
110 for (const result of message.toolResults || []) resultAt.set(result.tool_use_id, index);
111 });
112 const anchors = inputAnchors(input, useAt);
113 let spent = 0;
114 const pointers = [];
115 for (const call of calls.values()) {
116 const useIndex = useAt.get(call.id);
117 const resultIndex = resultAt.get(call.id);
118 if (useIndex === void 0 || resultIndex === void 0) continue;
119 const resultMessage = input[resultIndex];
120 const use = (input[useIndex]?.toolUses || []).find((entry) => entry.tool_use_id === call.id);
121 const original = (resultMessage?.toolResults || []).find((result) => result.tool_use_id === call.id);
122 if (!resultMessage || !use || !original) continue;
123 const intact = messages.some((message) => message.handle !== void 0 && message.handle === resultMessage.handle || message.handle === void 0 && (message.toolResults || []).some((result) => result.tool_use_id === call.id && result.text === original.text));
124 if (intact) continue;
125 if (nonTextResult(original)) {
126 report.skipped.push(call.id);
127 continue;
128 }
129 const bytes = utf8Bytes(original.text);
130 let reason = "";
131 if (bytes > cap.perResult) reason = `over the ${cap.perResult} B per-result cap`;
132 else if (spent + bytes > cap.total) reason = `past the ${cap.total} B restore budget`;
133 if (reason) {
134 pointers.push({ call, text: pointerText(call, bytes, reason), anchor: useIndex });
135 report.pointed.push(call.id);
136 continue;
137 }
138 spent += bytes;
139 const holds = (message) => (message.toolResults || []).some((result) => result.tool_use_id === call.id);
140 const callAt = messages.findIndex((message) => (message.toolUses || []).some((entry) => entry.tool_use_id === call.id));
141 if (callAt < 0) {
142 for (let index = messages.length - 1; index >= 0; index -= 1) {
143 const message = messages[index];
144 if (!message || message.handle !== void 0 || !holds(message)) continue;
145 const toolResults = (message.toolResults || []).filter((result) => result.tool_use_id !== call.id);
146 if (!message.text && !(message.toolUses || []).length && !toolResults.length) messages.splice(index, 1);
147 else messages[index] = { ...message, toolResults };
148 }
149 messages.splice(
150 insertionPoint(messages, useIndex, anchors),
151 0,
152 { role: "assistant", text: "", toolUses: [freshUse(use)] },
153 { role: "user", text: "", toolUses: [], toolResults: [freshResult(original)] }
154 );
155 report.restored.push(call.id);
156 continue;
157 }
158 const holderAt = messages.findIndex((message) => message.handle === void 0 && holds(message));
159 const holder = holderAt >= 0 ? messages[holderAt] : void 0;
160 if (holder) {
161 messages[holderAt] = {
162 ...holder,
163 toolResults: (holder.toolResults || []).map((result) => result.tool_use_id === call.id ? freshResult(original) : result)
164 };
165 } else {
166 const callMessage = messages[callAt];
167 const after = messages[callAt + 1];
168 const siblings = after !== void 0 && after.handle === void 0 && after.role === "user" && (after.toolResults || []).some((result) => (callMessage?.toolUses || []).some((entry) => entry.tool_use_id === result.tool_use_id));
169 if (after && siblings) messages[callAt + 1] = { ...after, toolResults: [...after.toolResults || [], freshResult(original)] };
170 else messages.splice(callAt + 1, 0, { role: "user", text: "", toolUses: [], toolResults: [freshResult(original)] });
171 }
172 report.restored.push(call.id);
173 }
174 for (const pointer of pointers) {
175 const holder = messages.findIndex((message2) => message2.handle === void 0 && (message2.toolResults || []).some((result) => result.tool_use_id === pointer.call.id));
176 const message = holder >= 0 ? messages[holder] : void 0;
177 if (message) {
178 messages[holder] = {
179 ...message,
180 toolResults: (message.toolResults || []).map((result) => result.tool_use_id === pointer.call.id ? { ...result, text: `${result.text}
181${pointer.text}` } : result)
182 };
183 } else {
184 messages.splice(insertionPoint(messages, pointer.anchor, anchors), 0, { role: "user", text: pointer.text, toolUses: [] });
185 }
186 }
187 return report;
188}
189var textKey = (message) => `${message.role}\0${message.text}`;
190function inputAnchors(input, useAt) {
191 const handleAt = /* @__PURE__ */ new Map();
192 const textAt = /* @__PURE__ */ new Map();
193 input.forEach((message, index) => {
194 if (message.handle) handleAt.set(message.handle, index);
195 if (message.text && !textAt.has(textKey(message))) textAt.set(textKey(message), index);
196 });
197 return { useAt, handleAt, textAt };
198}
199function insertionPoint(messages, target, anchors) {
200 let anchor = -1;
201 let point = messages.length;
202 for (const [index, message] of messages.entries()) {
203 const byHandle = message.handle !== void 0 ? anchors.handleAt.get(message.handle) : void 0;
204 const byBlock = blockIds(message).map((id) => anchors.useAt.get(id)).filter((at) => at !== void 0);
205 const byText = message.text ? anchors.textAt.get(textKey(message)) : void 0;
206 const own = byHandle ?? (byBlock.length ? Math.min(...byBlock) : byText);
207 if (own !== void 0) anchor = own;
208 if (anchor > target) {
209 point = index;
210 break;
211 }
212 }
213 return stepPastAnswers(messages, point);
214}
215function stepPastAnswers(messages, start) {
216 let point = start;
217 while (point > 0 && point < messages.length) {
218 const before = messages[point - 1];
219 const at = messages[point];
220 if (!before || !at) break;
221 const answers = (at.toolResults || []).some((result) => (before.toolUses || []).some((use) => use.tool_use_id === result.tool_use_id));
222 if (!answers) break;
223 point += 1;
224 }
225 return point;
226}
227function compactionPluginsBeneath(trace) {
228 return trace.filter((entry) => entry.tier !== "core" && entry.plugin !== "engine").map((entry) => entry.plugin);
229}
230function replacedBeneath(trace) {
231 return trace.length > 0 && !trace.some((entry) => entry.tier === "core" || entry.plugin === "engine");
232}
233async function readLiveRefs($) {
234 try {
235 const home = sidequestHome(await $.env.get("SIDEQUEST_HOME"), await $.env.get("HOME"), await $.env.get("CLAUDE_CONFIG_DIR"));
236 const session = await $.session.id();
237 if (!home || !session) return null;
238 const parsed = parseLiveRefs(await $.fs.read(liveRefsPath(home, session)));
239 return parsed ? { closed: new Set(parsed.closed), pin: parsed.pin } : null;
240 } catch (_) {
241 return null;
242 }
243}
244function jevEnabled(settings) {
245 const enabled = settings.enabledPlugins;
246 if (!enabled || typeof enabled !== "object") return false;
247 return Object.entries(enabled).some(([id, on]) => on === true && (id === JEV_PLUGIN || id.startsWith(`${JEV_PLUGIN}@`)));
248}
249var isJev = (plugin) => plugin === JEV_PLUGIN || plugin.startsWith(`${JEV_PLUGIN}@`);
250var PREPEND_NOTICE = 'sidequest compaction guard: fast-jev-compaction runs above sidequest, so its compactions can drop dispatch and briefing results. Add "sidequest@loadout" to prependPlugins in your user settings.json to seat sidequest outside it.';
251function register(on, options) {
252 const mode = compactionGuardMode(options?.compactionGuard);
253 if (mode === "off") return;
254 let pluginBeneath = mode === "on";
255 let orderSettled = false;
256 on("session.compact", async ($, e, next) => {
257 let live;
258 const liveRefs = async () => live === void 0 ? live = await readLiveRefs($) : live;
259 let down = e;
260 if (pluginBeneath && !String(e.instructions || "").includes(COMPACTION_RECOVERY_MARKER)) {
261 const pin = (await liveRefs())?.pin;
262 if (pin) down = { ...e, instructions: e.instructions ? `${e.instructions}
263
264${pin}` : pin };
265 }
266 const result = await next(down);
267 const trace = next.trace;
268 if (compactionPluginsBeneath(trace).length) pluginBeneath = true;
269 if (result.skip !== void 0 || !result.messages || !replacedBeneath(trace)) return result;
270 const calls = protectedCalls(e.messages, (await liveRefs())?.closed);
271 if (!calls.length) return result;
272 const restored = restoreProtected(e.messages, result.messages, calls);
273 if (!restored.restored.length && !restored.pointed.length) return result;
274 return { ...result, messages: restored.messages };
275 });
276 on("turn.complete", async ($, e, next) => {
277 const result = await next(e);
278 if (orderSettled) return result;
279 if (next.trace.some((entry) => isJev(entry.plugin))) {
280 pluginBeneath = true;
281 orderSettled = true;
282 return result;
283 }
284 try {
285 orderSettled = true;
286 if (jevEnabled(await $.settings.read())) $.ui.log(PREPEND_NOTICE);
287 } catch (_) {
288 }
289 return result;
290 });
291}
292export {
293 PER_RESULT_CAP_BYTES,
294 PREPEND_NOTICE,
295 TOTAL_CAP_BYTES,
296 compactionPluginsBeneath,
297 nonTextResult,
298 protectedCalls,
299 register,
300 replacedBeneath,
301 restoreProtected
302};
303