SLOPSHOPPER

sidequest

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…

new
v5.5.1MITupdated 2026-10-08poindexter12/loadout/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.

Setup guide · Generated reference · Loadout marketplace

Install

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.

Start with Claude

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 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.

License

MIT (c) Eigenwise

Source 1 files
hooks/fn/compaction.js 303 lines
1// 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