supipowers 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +3 -3
  2. package/skills/context-mode/SKILL.md +38 -0
  3. package/src/commands/config.ts +23 -2
  4. package/src/commands/fix-pr.ts +1 -1
  5. package/src/commands/plan.ts +1 -1
  6. package/src/commands/qa.ts +1 -1
  7. package/src/commands/release.ts +1 -1
  8. package/src/commands/review.ts +1 -1
  9. package/src/commands/run.ts +9 -4
  10. package/src/config/defaults.ts +10 -0
  11. package/src/config/schema.ts +10 -0
  12. package/src/context-mode/compressor.ts +200 -0
  13. package/src/context-mode/detector.ts +59 -0
  14. package/src/context-mode/event-extractor.ts +170 -0
  15. package/src/context-mode/event-store.ts +168 -0
  16. package/src/context-mode/hooks.ts +176 -0
  17. package/src/context-mode/installer.ts +71 -0
  18. package/src/context-mode/snapshot-builder.ts +127 -0
  19. package/src/discipline/debugging.ts +7 -7
  20. package/src/discipline/receiving-review.ts +5 -5
  21. package/src/discipline/tdd.ts +2 -2
  22. package/src/discipline/verification.ts +9 -9
  23. package/src/git/base-branch.ts +30 -0
  24. package/src/git/branch-finish.ts +12 -3
  25. package/src/git/sanitize.ts +19 -0
  26. package/src/git/worktree.ts +38 -11
  27. package/src/index.ts +8 -1
  28. package/src/orchestrator/agent-prompts.ts +15 -7
  29. package/src/orchestrator/conflict-resolver.ts +3 -2
  30. package/src/orchestrator/dispatcher.ts +76 -21
  31. package/src/orchestrator/prompts.ts +46 -6
  32. package/src/planning/plan-reviewer.ts +1 -1
  33. package/src/planning/plan-writer-prompt.ts +6 -9
  34. package/src/planning/prompt-builder.ts +17 -16
  35. package/src/planning/spec-reviewer.ts +2 -2
  36. package/src/types.ts +21 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "0.5.0",
4
- "description": "OMP-native workflow extension inspired by Superpowers.",
3
+ "version": "0.6.1",
4
+ "description": "OMP-native workflow extension inspired by supipowers.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "test": "vitest run",
@@ -13,7 +13,7 @@
13
13
  "omp-extension",
14
14
  "workflow",
15
15
  "agent",
16
- "superpowers"
16
+ "supipowers"
17
17
  ],
18
18
  "license": "MIT",
19
19
  "bin": {
@@ -0,0 +1,38 @@
1
+ # Context Mode — Tool Routing Instructions
2
+
3
+ When context-mode sandbox tools are available, prefer them over raw tool calls to keep your context window lean.
4
+
5
+ ## Tool Selection Hierarchy
6
+
7
+ Use context-mode tools in this priority order:
8
+
9
+ 1. **ctx_batch_execute** — for multi-step operations. Runs multiple commands and searches in a single call.
10
+ - Use when: you need to run 2+ commands, or combine a command with a search
11
+ - Example: checking a build AND searching for a symbol
12
+
13
+ 2. **ctx_search** — for querying previously indexed knowledge. No re-execution needed.
14
+ - Use when: you've already indexed data and need to find something in it
15
+ - Example: finding a function definition you indexed earlier
16
+
17
+ 3. **ctx_execute / ctx_execute_file** — for single commands or file processing.
18
+ - Use when: running one command whose output would be large
19
+ - Example: listing a directory, reading a large log file
20
+
21
+ 4. **Raw Bash/Read/Grep** — only when necessary.
22
+ - Use when: editing files (Read before Edit), running build/test commands where real-time output matters, or when the output is known to be small
23
+
24
+ ## Forbidden Patterns
25
+
26
+ - Do NOT use Bash for `curl`/`wget`/HTTP requests — use `ctx_fetch_and_index` instead
27
+ - Do NOT use Read for analyzing large files (>100 lines) — use `ctx_execute_file` to process and summarize
28
+ - Do NOT use Bash for directory listings with >20 expected files — use `ctx_execute`
29
+
30
+ ## Output Constraints
31
+
32
+ - Keep tool output responses under 500 words when possible
33
+ - Write large artifacts (generated code, data dumps) to files rather than returning them inline
34
+ - Prefer structured summaries over raw output
35
+
36
+ ## Sub-Agent Awareness
37
+
38
+ These routing instructions apply within sub-agent sessions. When you are a sub-agent dispatched by supipowers, follow the same tool preference hierarchy.
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
2
  import { loadConfig, updateConfig } from "../config/loader.js";
3
3
  import { listProfiles } from "../config/profiles.js";
4
+ import { checkInstallation } from "../context-mode/installer.js";
4
5
  import type { SupipowersConfig } from "../types.js";
5
6
 
6
7
  const FRAMEWORK_OPTIONS = [
@@ -126,7 +127,7 @@ function buildSettings(cwd: string): SettingDef[] {
126
127
  ];
127
128
  }
128
129
 
129
- export function handleConfig(ctx: ExtensionContext): void {
130
+ export function handleConfig(pi: ExtensionAPI, ctx: ExtensionContext): void {
130
131
  if (!ctx.hasUI) {
131
132
  ctx.ui.notify("Config UI requires interactive mode", "warning");
132
133
  return;
@@ -179,13 +180,33 @@ export function handleConfig(ctx: ExtensionContext): void {
179
180
  }
180
181
  }
181
182
  })();
183
+
184
+ // Context-mode status (async, fire-and-forget)
185
+ checkInstallation(
186
+ (cmd: string, args: string[]) => pi.exec(cmd, args),
187
+ pi.getActiveTools(),
188
+ ).then((status) => {
189
+ const lines = [
190
+ "",
191
+ "Context Mode:",
192
+ ` CLI installed: ${status.cliInstalled ? "\u2713" + (status.version ? ` v${status.version}` : "") : "\u2717"}`,
193
+ ` MCP configured: ${status.mcpConfigured ? "\u2713" : "\u2717"}`,
194
+ ` Tools available: ${status.toolsAvailable ? "\u2713" : "\u2717"}`,
195
+ ];
196
+ if (!status.mcpConfigured && status.cliInstalled) {
197
+ lines.push(" \u2192 Run `omp mcp add context-mode` to enable");
198
+ }
199
+ ctx.ui.notify(lines.join("\n"), "info");
200
+ }).catch(() => {
201
+ // Silently ignore — context-mode status is optional
202
+ });
182
203
  }
183
204
 
184
205
  export function registerConfigCommand(pi: ExtensionAPI): void {
185
206
  pi.registerCommand("supi:config", {
186
207
  description: "View and manage Supipowers configuration",
187
208
  async handler(_args, ctx) {
188
- handleConfig(ctx);
209
+ handleConfig(pi, ctx);
189
210
  },
190
211
  });
191
212
  }
@@ -172,7 +172,7 @@ export function registerFixPrCommand(pi: ExtensionAPI): void {
172
172
  content: [{ type: "text", text: prompt }],
173
173
  display: "none",
174
174
  },
175
- { deliverAs: "steer" },
175
+ { deliverAs: "steer", triggerTurn: true },
176
176
  );
177
177
 
178
178
  notifyInfo(ctx, `Fix-PR started: PR #${prNumber}`, `${commentCount} comments to assess | session ${ledger.id}`);
@@ -130,7 +130,7 @@ export function registerPlanCommand(pi: ExtensionAPI): void {
130
130
  content: [{ type: "text", text: prompt }],
131
131
  display: "none",
132
132
  },
133
- { deliverAs: "steer" }
133
+ { deliverAs: "steer", triggerTurn: true }
134
134
  );
135
135
 
136
136
  notifyInfo(ctx, "Planning started", args ? `Topic: ${args}` : "Describe what you want to build");
@@ -256,7 +256,7 @@ export function registerQaCommand(pi: ExtensionAPI): void {
256
256
  content: [{ type: "text", text: prompt }],
257
257
  display: "none",
258
258
  },
259
- { deliverAs: "steer" },
259
+ { deliverAs: "steer", triggerTurn: true },
260
260
  );
261
261
 
262
262
  notifyInfo(
@@ -40,7 +40,7 @@ export function registerReleaseCommand(pi: ExtensionAPI): void {
40
40
  content: [{ type: "text", text: prompt }],
41
41
  display: "none",
42
42
  },
43
- { deliverAs: "steer" }
43
+ { deliverAs: "steer", triggerTurn: true }
44
44
  );
45
45
  },
46
46
  });
@@ -92,7 +92,7 @@ export function registerReviewCommand(pi: ExtensionAPI): void {
92
92
  content: [{ type: "text", text: reviewPrompt }],
93
93
  display: "none",
94
94
  },
95
- { deliverAs: "steer" }
95
+ { deliverAs: "steer", triggerTurn: true }
96
96
  );
97
97
  },
98
98
  });
@@ -15,6 +15,7 @@ import { dispatchAgent, dispatchAgentWithReview, dispatchFixAgent } from "../orc
15
15
  import { summarizeBatch, buildRunSummary } from "../orchestrator/result-collector.js";
16
16
  import { analyzeConflicts } from "../orchestrator/conflict-resolver.js";
17
17
  import { isLspAvailable } from "../lsp/detector.js";
18
+ import { detectContextMode } from "../context-mode/detector.js";
18
19
  import {
19
20
  notifyInfo,
20
21
  notifySuccess,
@@ -24,6 +25,7 @@ import {
24
25
  } from "../notifications/renderer.js";
25
26
  import { buildWorktreePrompt } from "../git/worktree.js";
26
27
  import { buildBranchFinishPrompt } from "../git/branch-finish.js";
28
+ import { detectBaseBranch } from "../git/base-branch.js";
27
29
  import type { RunManifest, AgentResult } from "../types.js";
28
30
 
29
31
  export function registerRunCommand(pi: ExtensionAPI): void {
@@ -88,7 +90,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
88
90
  content: [{ type: "text", text: worktreeInstructions }],
89
91
  display: "none",
90
92
  },
91
- { deliverAs: "steer" },
93
+ { deliverAs: "steer", triggerTurn: true },
92
94
  );
93
95
  notifyInfo(ctx, "Setting up worktree", `Branch: ${branchName}`);
94
96
  }
@@ -104,6 +106,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
104
106
  }
105
107
  const plan = parsePlan(planContent, manifest.planRef);
106
108
  const lsp = isLspAvailable(pi.getActiveTools());
109
+ const ctxMode = detectContextMode(pi.getActiveTools()).available;
107
110
 
108
111
  for (const batch of manifest.batches) {
109
112
  if (batch.status === "completed") continue;
@@ -129,6 +132,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
129
132
  planContext: plan.context,
130
133
  config,
131
134
  lspAvailable: lsp,
135
+ contextModeAvailable: ctxMode,
132
136
  });
133
137
  });
134
138
 
@@ -140,7 +144,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
140
144
  }
141
145
  }
142
146
 
143
- const conflicts = analyzeConflicts(batchResults, plan.tasks);
147
+ const conflicts = analyzeConflicts(batchResults, plan.tasks, ctxMode);
144
148
  if (conflicts.hasConflicts) {
145
149
  notifyWarning(
146
150
  ctx,
@@ -164,6 +168,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
164
168
  planContext: plan.context,
165
169
  config,
166
170
  lspAvailable: lsp,
171
+ contextModeAvailable: ctxMode,
167
172
  previousOutput: failed.output,
168
173
  failureReason: failed.output,
169
174
  });
@@ -208,7 +213,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
208
213
  if (branchName && manifest.status === "completed") {
209
214
  const finishInstructions = buildBranchFinishPrompt({
210
215
  branchName,
211
- baseBranch: "main",
216
+ baseBranch: await detectBaseBranch((cmd, args) => pi.exec(cmd, args)),
212
217
  });
213
218
  pi.sendMessage(
214
219
  {
@@ -216,7 +221,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
216
221
  content: [{ type: "text", text: finishInstructions }],
217
222
  display: "none",
218
223
  },
219
- { deliverAs: "steer" },
224
+ { deliverAs: "steer", triggerTurn: true },
220
225
  );
221
226
  notifyInfo(ctx, "Run succeeded", "Follow branch finish instructions to integrate your work");
222
227
  }
@@ -24,6 +24,16 @@ export const DEFAULT_CONFIG: SupipowersConfig = {
24
24
  release: {
25
25
  pipeline: null,
26
26
  },
27
+ contextMode: {
28
+ enabled: true,
29
+ compressionThreshold: 4096,
30
+ blockHttpCommands: true,
31
+ routingInstructions: true,
32
+ eventTracking: true,
33
+ compaction: true,
34
+ llmSummarization: false,
35
+ llmThreshold: 16384,
36
+ },
27
37
  };
28
38
 
29
39
  export const BUILTIN_PROFILES: Record<string, Profile> = {
@@ -30,6 +30,16 @@ const ConfigSchema = Type.Object({
30
30
  release: Type.Object({
31
31
  pipeline: Type.Union([Type.String(), Type.Null()]),
32
32
  }),
33
+ contextMode: Type.Object({
34
+ enabled: Type.Boolean(),
35
+ compressionThreshold: Type.Number({ minimum: 1024 }),
36
+ blockHttpCommands: Type.Boolean(),
37
+ routingInstructions: Type.Boolean(),
38
+ eventTracking: Type.Boolean(),
39
+ compaction: Type.Boolean(),
40
+ llmSummarization: Type.Boolean(),
41
+ llmThreshold: Type.Number({ minimum: 4096 }),
42
+ }),
33
43
  });
34
44
 
35
45
  export function validateConfig(data: unknown): { valid: boolean; errors: string[] } {
@@ -0,0 +1,200 @@
1
+ // src/context-mode/compressor.ts
2
+
3
+ interface ToolResultEventLike {
4
+ toolName: string;
5
+ input: Record<string, unknown>;
6
+ content: Array<{ type: string; text?: string }>;
7
+ isError: boolean;
8
+ details: unknown;
9
+ }
10
+
11
+ interface ToolResultEventResult {
12
+ content?: Array<{ type: string; text: string }>;
13
+ }
14
+
15
+ const BASH_HEAD_LINES = 5;
16
+ const BASH_TAIL_LINES = 10;
17
+ const READ_PREVIEW_LINES = 10;
18
+ const GREP_MAX_MATCHES = 10;
19
+ const FIND_MAX_PATHS = 20;
20
+
21
+ /** Measure total byte length of text content entries */
22
+ function measureTextBytes(content: Array<{ type: string; text?: string }>): number {
23
+ let total = 0;
24
+ for (const entry of content) {
25
+ if (entry.type === "text" && entry.text) {
26
+ total += new TextEncoder().encode(entry.text).byteLength;
27
+ }
28
+ }
29
+ return total;
30
+ }
31
+
32
+ /** Check if content contains any non-text entries */
33
+ function hasNonTextContent(content: Array<{ type: string }>): boolean {
34
+ return content.some((entry) => entry.type !== "text");
35
+ }
36
+
37
+ /** Get combined text from all text content entries */
38
+ function getCombinedText(content: Array<{ type: string; text?: string }>): string {
39
+ return content
40
+ .filter((entry) => entry.type === "text" && entry.text)
41
+ .map((entry) => entry.text!)
42
+ .join("\n");
43
+ }
44
+
45
+ /** Compress bash tool output */
46
+ function compressBash(text: string, details: unknown): string | undefined {
47
+ const exitCode =
48
+ details && typeof details === "object" && "exitCode" in details
49
+ ? (details as { exitCode: number }).exitCode
50
+ : 0;
51
+
52
+ // Non-zero exit: keep full output for debugging
53
+ if (exitCode !== 0) return undefined;
54
+
55
+ const lines = text.split("\n");
56
+ const totalLines = lines.length;
57
+
58
+ if (totalLines <= BASH_HEAD_LINES + BASH_TAIL_LINES) return undefined;
59
+
60
+ const head = lines.slice(0, BASH_HEAD_LINES);
61
+ const tail = lines.slice(-BASH_TAIL_LINES);
62
+ const omitted = totalLines - BASH_HEAD_LINES - BASH_TAIL_LINES;
63
+
64
+ return [
65
+ ...head,
66
+ `[...compressed: ${omitted} lines omitted (${totalLines} lines total)...]`,
67
+ ...tail,
68
+ ].join("\n");
69
+ }
70
+
71
+ /** Compress read tool output */
72
+ function compressRead(text: string, input: Record<string, unknown>): string | undefined {
73
+ // Scoped reads (offset/limit) are already targeted — pass through
74
+ if (input.offset !== undefined || input.limit !== undefined) return undefined;
75
+
76
+ const lines = text.split("\n");
77
+ const totalLines = lines.length;
78
+ const path = typeof input.path === "string" ? input.path : "unknown";
79
+
80
+ if (totalLines <= READ_PREVIEW_LINES) return undefined;
81
+
82
+ const preview = lines.slice(0, READ_PREVIEW_LINES);
83
+ return [
84
+ `File: ${path} (${totalLines} lines total)`,
85
+ "",
86
+ ...preview,
87
+ `[...compressed: remaining ${totalLines - READ_PREVIEW_LINES} lines omitted...]`,
88
+ ].join("\n");
89
+ }
90
+
91
+ /** Compress grep tool output */
92
+ function compressGrep(text: string): string | undefined {
93
+ const lines = text.split("\n").filter((l) => l.length > 0);
94
+ const totalMatches = lines.length;
95
+
96
+ if (totalMatches <= GREP_MAX_MATCHES) return undefined;
97
+
98
+ const kept = lines.slice(0, GREP_MAX_MATCHES);
99
+ return [
100
+ `${totalMatches} matches total, showing first ${GREP_MAX_MATCHES}:`,
101
+ "",
102
+ ...kept,
103
+ `[...compressed: ${totalMatches - GREP_MAX_MATCHES} more matches omitted...]`,
104
+ ].join("\n");
105
+ }
106
+
107
+ /** Compress find tool output */
108
+ function compressFind(text: string): string | undefined {
109
+ const lines = text.split("\n").filter((l) => l.length > 0);
110
+ const totalFiles = lines.length;
111
+
112
+ if (totalFiles <= FIND_MAX_PATHS) return undefined;
113
+
114
+ const kept = lines.slice(0, FIND_MAX_PATHS);
115
+ return [
116
+ `${totalFiles} files found, showing first ${FIND_MAX_PATHS}:`,
117
+ "",
118
+ ...kept,
119
+ `[...compressed: ${totalFiles - FIND_MAX_PATHS} more files omitted...]`,
120
+ ].join("\n");
121
+ }
122
+
123
+ /** Compress a tool result if it exceeds the threshold */
124
+ export function compressToolResult(
125
+ event: ToolResultEventLike,
126
+ threshold: number,
127
+ ): ToolResultEventResult | undefined {
128
+ // General rules: pass through errors, non-text content, and small outputs
129
+ if (event.isError) return undefined;
130
+ if (hasNonTextContent(event.content)) return undefined;
131
+ if (measureTextBytes(event.content) <= threshold) return undefined;
132
+
133
+ const text = getCombinedText(event.content);
134
+ let compressed: string | undefined;
135
+
136
+ switch (event.toolName) {
137
+ case "bash":
138
+ compressed = compressBash(text, event.details);
139
+ break;
140
+ case "read":
141
+ compressed = compressRead(text, event.input);
142
+ break;
143
+ case "grep":
144
+ compressed = compressGrep(text);
145
+ break;
146
+ case "find":
147
+ compressed = compressFind(text);
148
+ break;
149
+ default:
150
+ return undefined;
151
+ }
152
+
153
+ if (!compressed) return undefined;
154
+ return { content: [{ type: "text", text: compressed }] };
155
+ }
156
+
157
+ /** Summarization prompt templates by tool type */
158
+ const SUMMARIZE_PROMPTS: Record<string, string> = {
159
+ bash: "Summarize this command output. Preserve: exit code, key findings, error messages, file paths mentioned. Be concise (under 200 words).",
160
+ read: "Summarize this file content. Preserve: file structure, key exports/functions, notable patterns. Be concise (under 200 words).",
161
+ grep: "Summarize these search results. Preserve: match count, most relevant matches, file distribution. Be concise (under 200 words).",
162
+ find: "Summarize these file paths. Preserve: directory structure, file count, key patterns. Be concise (under 200 words).",
163
+ };
164
+
165
+ /** Compress with optional LLM summarization for very large outputs */
166
+ export async function compressToolResultWithLLM(
167
+ event: ToolResultEventLike,
168
+ threshold: number,
169
+ llmThreshold: number,
170
+ summarize: (text: string, toolName: string) => Promise<string>,
171
+ ): Promise<ToolResultEventResult | undefined> {
172
+ // General rules
173
+ if (event.isError) return undefined;
174
+ if (hasNonTextContent(event.content)) return undefined;
175
+ const byteSize = measureTextBytes(event.content);
176
+ if (byteSize <= threshold) return undefined;
177
+
178
+ const text = getCombinedText(event.content);
179
+
180
+ // Below LLM threshold: use structural compression
181
+ if (byteSize < llmThreshold) {
182
+ return compressToolResult(event, threshold);
183
+ }
184
+
185
+ // Above LLM threshold: try LLM summarization
186
+ try {
187
+ const prompt = SUMMARIZE_PROMPTS[event.toolName] ?? "Summarize this output concisely (under 200 words).";
188
+ const summary = await summarize(`${prompt}\n\n${text}`, event.toolName);
189
+
190
+ // Validate: non-empty and reasonably sized
191
+ if (summary && summary.length >= 50) {
192
+ return { content: [{ type: "text", text: summary }] };
193
+ }
194
+ } catch {
195
+ // Fall through to structural compression
196
+ }
197
+
198
+ // Fallback
199
+ return compressToolResult(event, threshold);
200
+ }
@@ -0,0 +1,59 @@
1
+ // src/context-mode/detector.ts
2
+
3
+ /** Which context-mode MCP tools are available in the current session */
4
+ export interface ContextModeStatus {
5
+ available: boolean;
6
+ tools: {
7
+ ctxExecute: boolean;
8
+ ctxBatchExecute: boolean;
9
+ ctxExecuteFile: boolean;
10
+ ctxIndex: boolean;
11
+ ctxSearch: boolean;
12
+ ctxFetchAndIndex: boolean;
13
+ };
14
+ }
15
+
16
+ /** Suffixes to match against full MCP-namespaced tool names */
17
+ const TOOL_SUFFIXES: Array<[string, keyof ContextModeStatus["tools"]]> = [
18
+ ["ctx_execute", "ctxExecute"],
19
+ ["ctx_batch_execute", "ctxBatchExecute"],
20
+ ["ctx_execute_file", "ctxExecuteFile"],
21
+ ["ctx_index", "ctxIndex"],
22
+ ["ctx_search", "ctxSearch"],
23
+ ["ctx_fetch_and_index", "ctxFetchAndIndex"],
24
+ ];
25
+
26
+ /**
27
+ * Extract the short tool name from a potentially MCP-namespaced tool name.
28
+ * MCP tools use the format: mcp__<server>__<tool_name>
29
+ * Native tools use bare names like: lsp, bash, etc.
30
+ */
31
+ function getShortName(tool: string): string {
32
+ const lastSep = tool.lastIndexOf("__");
33
+ return lastSep >= 0 ? tool.slice(lastSep + 2) : tool;
34
+ }
35
+
36
+ /** Detect context-mode MCP tool availability from the active tools list */
37
+ export function detectContextMode(activeTools: string[]): ContextModeStatus {
38
+ const tools: ContextModeStatus["tools"] = {
39
+ ctxExecute: false,
40
+ ctxBatchExecute: false,
41
+ ctxExecuteFile: false,
42
+ ctxIndex: false,
43
+ ctxSearch: false,
44
+ ctxFetchAndIndex: false,
45
+ };
46
+
47
+ for (const tool of activeTools) {
48
+ const shortName = getShortName(tool);
49
+ for (const [suffix, key] of TOOL_SUFFIXES) {
50
+ if (shortName === suffix) {
51
+ tools[key] = true;
52
+ break;
53
+ }
54
+ }
55
+ }
56
+
57
+ const available = Object.values(tools).some(Boolean);
58
+ return { available, tools };
59
+ }