@aefree/pi-plastic 0.4.0

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 (61) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/CONTRIBUTING.md +88 -0
  3. package/LICENSE +21 -0
  4. package/README.md +181 -0
  5. package/SECURITY.md +31 -0
  6. package/evals/tool-loading/README.md +38 -0
  7. package/evals/tool-loading/RESULTS.md +57 -0
  8. package/evals/tool-loading/baseline.json +34 -0
  9. package/evals/tool-loading/cases.json +70 -0
  10. package/evals/tool-loading/config.json +19 -0
  11. package/evals/tool-loading/mutation-guard.ts +30 -0
  12. package/evals/tool-loading/provider-capture.ts +22 -0
  13. package/evals/tool-loading/results/.gitignore +3 -0
  14. package/evals/tool-loading/run-eval.ts +372 -0
  15. package/extensions/bash-cm-diff-guard.ts +49 -0
  16. package/extensions/bash-cm-merge-guard.ts +53 -0
  17. package/extensions/plastic-branch-status.ts +285 -0
  18. package/extensions/repository-search-provider.ts +66 -0
  19. package/extensions/shared/bash-command-guards.ts +67 -0
  20. package/index.ts +777 -0
  21. package/package.json +76 -0
  22. package/skills/_shared/references/conventional-commits.md +198 -0
  23. package/skills/using-plastic/SKILL.md +50 -0
  24. package/skills/using-plastic/references/branch-management.md +78 -0
  25. package/skills/using-plastic/references/changeset-operations.md +96 -0
  26. package/skills/using-plastic/references/code-review-creation.md +30 -0
  27. package/skills/using-plastic/references/integration.md +47 -0
  28. package/skills/using-plastic/references/quick-reference.md +32 -0
  29. package/skills/using-plastic/references/resources.md +7 -0
  30. package/skills/using-plastic/references/reviewing-changes.md +71 -0
  31. package/skills/using-plastic/references/shelving.md +25 -0
  32. package/skills/using-plastic/references/troubleshooting.md +115 -0
  33. package/skills/using-plastic/references/workspaces.md +33 -0
  34. package/src/pi-tool-compat.ts +56 -0
  35. package/src/plastic-core.ts +4204 -0
  36. package/src/plastic-tool-loading.ts +234 -0
  37. package/src/plastic-workspace.ts +176 -0
  38. package/src/repository-search-provider.ts +64 -0
  39. package/tests/bash-cm-diff-guard-validation.ts +55 -0
  40. package/tests/fixtures/plastic.selector +3 -0
  41. package/tests/fixtures/status-compact.txt +1 -0
  42. package/tests/fixtures/status-normal.txt +1 -0
  43. package/tests/packed-copy-owner-conflict.test.ts +28 -0
  44. package/tests/pi-tool-harness.ts +116 -0
  45. package/tests/plastic-branch-create-live.test.ts +78 -0
  46. package/tests/plastic-branch-query.test.ts +26 -0
  47. package/tests/plastic-branch-status.test.ts +241 -0
  48. package/tests/plastic-checkin-path-resolution.ts +219 -0
  49. package/tests/plastic-constrained-sampling.test.ts +77 -0
  50. package/tests/plastic-diff-workflow.test.ts +86 -0
  51. package/tests/plastic-dynamic-tool-loading.test.ts +224 -0
  52. package/tests/plastic-extension-registration.test.ts +34 -0
  53. package/tests/plastic-live-smoke.test.ts +37 -0
  54. package/tests/plastic-mutation-execution.test.ts +61 -0
  55. package/tests/plastic-patch-command.test.ts +118 -0
  56. package/tests/plastic-process-lifecycle.test.ts +148 -0
  57. package/tests/plastic-tool-rendering.test.ts +16 -0
  58. package/tests/plastic-tool-validation.ts +153 -0
  59. package/tests/plastic-workspace.test.ts +62 -0
  60. package/tests/repository-search-provider-registration.test.ts +37 -0
  61. package/tests/repository-search-provider.test.ts +27 -0
@@ -0,0 +1,285 @@
1
+ import { watch, type FSWatcher } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ discoverPlasticWorkspace,
7
+ parsePlasticSelector,
8
+ parsePlasticStatusBranch,
9
+ sanitizePlasticBranch,
10
+ type WorkspaceDiscoveryOutcome,
11
+ } from "../src/plastic-workspace";
12
+
13
+ const STATUS_KEY = "plastic-branch";
14
+ const DEBOUNCE_MS = 150;
15
+ const CM_TIMEOUT_MS = 5_000;
16
+
17
+ type Timer = ReturnType<typeof setTimeout>;
18
+ type WatchHandle = { close(): void };
19
+
20
+ type BranchStatusDependencies = {
21
+ discoverWorkspace(cwd: string): Promise<WorkspaceDiscoveryOutcome>;
22
+ readSelector(path: string): Promise<string | undefined>;
23
+ watchDirectory(path: string, onChange: () => void, onError: () => void): WatchHandle;
24
+ confirmBranch(cwd: string, signal: AbortSignal): Promise<string>;
25
+ setTimeout(callback: () => void, delay: number): Timer;
26
+ clearTimeout(timer: Timer): void;
27
+ platform: NodeJS.Platform;
28
+ };
29
+
30
+ const createDefaultDependencies = (pi: ExtensionAPI): BranchStatusDependencies => ({
31
+ discoverWorkspace: discoverPlasticWorkspace,
32
+ async readSelector(path) {
33
+ try {
34
+ return await readFile(path, "utf8");
35
+ } catch (error) {
36
+ if (error instanceof Error && ["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return undefined;
37
+ throw error;
38
+ }
39
+ },
40
+ watchDirectory(path, onChange, onError) {
41
+ const watcher: FSWatcher = watch(path, { persistent: false }, onChange);
42
+ watcher.on("error", onError);
43
+ return watcher;
44
+ },
45
+ async confirmBranch(cwd, signal) {
46
+ const executable = process.env.PI_PLASTIC_CM_EXECUTABLE?.trim() || "cm";
47
+ const result = await pi.exec(executable, ["status"], { cwd, signal, timeout: CM_TIMEOUT_MS });
48
+ if (result.code !== 0) throw new Error(result.stderr.trim() || `cm status failed with exit code ${result.code}`);
49
+ return result.stdout;
50
+ },
51
+ setTimeout,
52
+ clearTimeout,
53
+ platform: process.platform,
54
+ });
55
+
56
+ const pathKey = (path: string, platform: NodeJS.Platform): string => {
57
+ const normalized = path.replace(/\\/g, "/").replace(/\/$/, "");
58
+ return platform === "win32" ? normalized.toLowerCase() : normalized;
59
+ };
60
+
61
+ export function createPlasticBranchStatusExtension(overrides: Partial<BranchStatusDependencies> = {}) {
62
+ return function plasticBranchStatus(pi: ExtensionAPI): void {
63
+ const dependencies = { ...createDefaultDependencies(pi), ...overrides };
64
+ let sessionContext: ExtensionContext | undefined;
65
+ let active = false;
66
+ let generation = 0;
67
+ let refreshSequence = 0;
68
+ let workspace: Extract<WorkspaceDiscoveryOutcome, { kind: "found" }>["value"] | undefined;
69
+ let watcher: WatchHandle | undefined;
70
+ let watchedDirectory: string | undefined;
71
+ let recoveryWatcher: WatchHandle | undefined;
72
+ let recoveryWatchedDirectory: string | undefined;
73
+ let watcherNeedsRebind = false;
74
+ let recoveryRoot: string | undefined;
75
+ let debounceTimer: Timer | undefined;
76
+ let confirmationTimer: Timer | undefined;
77
+ let confirmationController: AbortController | undefined;
78
+ const toolArguments = new Map<string, unknown>();
79
+
80
+ const clearStatus = (): void => sessionContext?.ui.setStatus(STATUS_KEY, undefined);
81
+
82
+ const closeWatcher = (): void => {
83
+ try { watcher?.close(); } catch { /* already closed */ }
84
+ watcher = undefined;
85
+ watchedDirectory = undefined;
86
+ watcherNeedsRebind = false;
87
+ };
88
+
89
+ const closeRecoveryWatcher = (): void => {
90
+ try { recoveryWatcher?.close(); } catch { /* already closed */ }
91
+ recoveryWatcher = undefined;
92
+ recoveryWatchedDirectory = undefined;
93
+ };
94
+
95
+ const cancelConfirmation = (): void => {
96
+ confirmationController?.abort();
97
+ confirmationController = undefined;
98
+ if (confirmationTimer) dependencies.clearTimeout(confirmationTimer);
99
+ confirmationTimer = undefined;
100
+ };
101
+
102
+ const disposeRuntime = (): void => {
103
+ active = false;
104
+ generation++;
105
+ refreshSequence++;
106
+ if (debounceTimer) dependencies.clearTimeout(debounceTimer);
107
+ debounceTimer = undefined;
108
+ cancelConfirmation();
109
+ closeWatcher();
110
+ closeRecoveryWatcher();
111
+ toolArguments.clear();
112
+ workspace = undefined;
113
+ recoveryRoot = undefined;
114
+ clearStatus();
115
+ sessionContext = undefined;
116
+ };
117
+
118
+ const displayBranch = (branch: string): boolean => {
119
+ const safeBranch = sanitizePlasticBranch(branch, 80);
120
+ if (!safeBranch || !sessionContext) return false;
121
+ const theme = sessionContext.ui.theme;
122
+ sessionContext.ui.setStatus(STATUS_KEY, `${theme.fg("accent", "Plastic")} ${theme.fg("dim", safeBranch)}`);
123
+ return true;
124
+ };
125
+
126
+ const displayUnavailable = (): void => {
127
+ if (!sessionContext) return;
128
+ sessionContext.ui.setStatus(STATUS_KEY, sessionContext.ui.theme.fg("warning", "Plastic branch unavailable"));
129
+ };
130
+
131
+ let refresh: () => Promise<void>;
132
+ const scheduleRefresh = (rebindWatcher = false): void => {
133
+ if (!active) return;
134
+ watcherNeedsRebind ||= rebindWatcher;
135
+ if (debounceTimer) dependencies.clearTimeout(debounceTimer);
136
+ debounceTimer = dependencies.setTimeout(() => {
137
+ debounceTimer = undefined;
138
+ void refresh();
139
+ }, DEBOUNCE_MS);
140
+ };
141
+
142
+ const bindRecoveryWatcher = (directory: string): void => {
143
+ if (recoveryWatcher && recoveryWatchedDirectory === directory) return;
144
+ closeRecoveryWatcher();
145
+ try {
146
+ recoveryWatcher = dependencies.watchDirectory(
147
+ directory,
148
+ () => scheduleRefresh(true),
149
+ () => scheduleRefresh(true),
150
+ );
151
+ recoveryWatchedDirectory = directory;
152
+ } catch {
153
+ // Another workspace or tool event can still trigger revalidation.
154
+ }
155
+ };
156
+
157
+ const bindWatcher = (directory: string, force = false): boolean => {
158
+ if (!force && watcher && watchedDirectory === directory) return true;
159
+ closeWatcher();
160
+ try {
161
+ watcher = dependencies.watchDirectory(
162
+ directory,
163
+ () => scheduleRefresh(true),
164
+ () => scheduleRefresh(true),
165
+ );
166
+ watchedDirectory = directory;
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
171
+ };
172
+
173
+ refresh = async (): Promise<void> => {
174
+ if (!active || !sessionContext) return;
175
+ const ownedGeneration = generation;
176
+ const ownedRefresh = ++refreshSequence;
177
+ cancelConfirmation();
178
+
179
+ const previousWorkspaceRoot = workspace?.root ?? recoveryRoot;
180
+ const discovery = await dependencies.discoverWorkspace(sessionContext.cwd);
181
+ if (!active || generation !== ownedGeneration || refreshSequence !== ownedRefresh) return;
182
+ if (discovery.kind !== "found") {
183
+ const rootToWatch = workspace?.root ?? recoveryRoot;
184
+ workspace = undefined;
185
+ recoveryRoot = rootToWatch;
186
+ clearStatus();
187
+ if (rootToWatch) bindWatcher(rootToWatch, watcherNeedsRebind || watchedDirectory !== rootToWatch);
188
+ else closeWatcher();
189
+ return;
190
+ }
191
+
192
+ const discoveredWorkspace = discovery.value;
193
+ workspace = discoveredWorkspace;
194
+ recoveryRoot = discoveredWorkspace.root;
195
+ const discoveredKey = pathKey(discoveredWorkspace.root, dependencies.platform);
196
+ const previousKey = previousWorkspaceRoot ? pathKey(previousWorkspaceRoot, dependencies.platform) : undefined;
197
+ if (previousWorkspaceRoot && previousKey !== discoveredKey && previousKey?.startsWith(`${discoveredKey}/`)) {
198
+ // Discovery fell back to an enclosing workspace while a nested marker was replaced.
199
+ // Keep watching the former nested root so its recreated .plastic directory wins again.
200
+ bindRecoveryWatcher(previousWorkspaceRoot);
201
+ } else if (recoveryWatchedDirectory && pathKey(recoveryWatchedDirectory, dependencies.platform) === discoveredKey) {
202
+ closeRecoveryWatcher();
203
+ }
204
+ const shouldRebindWatcher = watcherNeedsRebind;
205
+ if (!bindWatcher(discoveredWorkspace.plasticDir, shouldRebindWatcher)) {
206
+ bindWatcher(discoveredWorkspace.root, true);
207
+ }
208
+
209
+ let selectorBranch: string | undefined;
210
+ try {
211
+ const selectorText = await dependencies.readSelector(join(discoveredWorkspace.plasticDir, "plastic.selector"));
212
+ if (selectorText !== undefined) {
213
+ const selector = parsePlasticSelector(selectorText);
214
+ if (selector.kind === "found") selectorBranch = selector.value.branch;
215
+ }
216
+ } catch {
217
+ // Selector I/O failure only disables the local fast path.
218
+ }
219
+ if (!active || generation !== ownedGeneration || refreshSequence !== ownedRefresh) return;
220
+
221
+ const hasSelectorBranch = selectorBranch ? displayBranch(selectorBranch) : false;
222
+ if (!hasSelectorBranch) displayUnavailable();
223
+
224
+ const controller = new AbortController();
225
+ confirmationController = controller;
226
+ const ownedConfirmationTimer = dependencies.setTimeout(() => controller.abort(), CM_TIMEOUT_MS);
227
+ confirmationTimer = ownedConfirmationTimer;
228
+ try {
229
+ const output = await dependencies.confirmBranch(discoveredWorkspace.root, controller.signal);
230
+ const parsed = parsePlasticStatusBranch(output);
231
+ if (!active || generation !== ownedGeneration || refreshSequence !== ownedRefresh || controller.signal.aborted) return;
232
+ if (parsed.kind === "found" && parsed.value.branch) displayBranch(parsed.value.branch);
233
+ else if (!hasSelectorBranch) displayUnavailable();
234
+ } catch {
235
+ if (active && generation === ownedGeneration && refreshSequence === ownedRefresh && !hasSelectorBranch) displayUnavailable();
236
+ } finally {
237
+ if (confirmationController === controller) {
238
+ confirmationController = undefined;
239
+ dependencies.clearTimeout(ownedConfirmationTimer);
240
+ confirmationTimer = undefined;
241
+ }
242
+ }
243
+ };
244
+
245
+ pi.on("session_start", async (_event, ctx) => {
246
+ disposeRuntime();
247
+ if (!ctx.hasUI) return;
248
+ sessionContext = ctx;
249
+ active = true;
250
+ generation++;
251
+ await refresh();
252
+ });
253
+
254
+ pi.on("tool_result", (event) => {
255
+ if (event.toolName.startsWith("plastic_") && !event.isError) toolArguments.set(event.toolCallId, event.input);
256
+ });
257
+
258
+ pi.on("tool_execution_end", async (event, ctx) => {
259
+ const args = toolArguments.get(event.toolCallId);
260
+ toolArguments.delete(event.toolCallId);
261
+ if (!active || event.isError || !event.toolName.startsWith("plastic_") || !workspace) return;
262
+ const input = args && typeof args === "object" ? args as { workdir?: unknown } : {};
263
+ const requestedWorkdir = typeof input.workdir === "string" ? input.workdir.trim() : "";
264
+ const workdir = requestedWorkdir
265
+ ? (isAbsolute(requestedWorkdir) ? requestedWorkdir : resolve(ctx.cwd, requestedWorkdir))
266
+ : ctx.cwd;
267
+ const toolWorkspace = await dependencies.discoverWorkspace(workdir);
268
+ if (!active || toolWorkspace.kind !== "found" || !workspace) return;
269
+ if (pathKey(toolWorkspace.value.root, dependencies.platform) === pathKey(workspace.root, dependencies.platform)) {
270
+ scheduleRefresh();
271
+ }
272
+ });
273
+
274
+ pi.on("session_shutdown", () => disposeRuntime());
275
+ };
276
+ }
277
+
278
+ export const __plasticBranchStatusInternals = {
279
+ STATUS_KEY,
280
+ DEBOUNCE_MS,
281
+ CM_TIMEOUT_MS,
282
+ pathKey,
283
+ };
284
+
285
+ export default createPlasticBranchStatusExtension();
@@ -0,0 +1,66 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createPlasticRepositorySearchPolicyV1, loadPlasticOwnerV1 } from "../src/repository-search-provider.ts";
3
+
4
+ const REPO_SEARCH_CONTRACT_RUNTIME = "@aefree/pi-repo-search/contracts/" + "v1";
5
+
6
+ type PolicyToken = { readonly id: string; readonly nonce: string };
7
+ type RepositoryPolicyRegistry = {
8
+ register(scope: object, policy: ReturnType<typeof createPlasticRepositorySearchPolicyV1>): PolicyToken;
9
+ unregister(token: PolicyToken): boolean;
10
+ };
11
+ type RepoSearchContractRuntime = {
12
+ createRepositoryPolicyRegistryV1?: () => RepositoryPolicyRegistry;
13
+ };
14
+ type RuntimeLoader = () => Promise<RepoSearchContractRuntime>;
15
+ type ScopeRegistrations = Readonly<{ policyToken: PolicyToken; registry: RepositoryPolicyRegistry }>;
16
+
17
+ /** Accept only failures resolving the optional package itself; installed-package defects remain visible. */
18
+ export function isMissingRepoSearchRuntime(error: unknown): boolean {
19
+ if (typeof error !== "object" || error === null) return false;
20
+ const candidate = error as { code?: unknown; message?: unknown };
21
+ if (candidate.code !== "ERR_MODULE_NOT_FOUND" && candidate.code !== "MODULE_NOT_FOUND") return false;
22
+ if (typeof candidate.message !== "string") return false;
23
+ return candidate.message.includes("Cannot find package '@aefree/pi-repo-search'") ||
24
+ candidate.message.includes(`'${REPO_SEARCH_CONTRACT_RUNTIME}'`) ||
25
+ candidate.message.includes(`\"${REPO_SEARCH_CONTRACT_RUNTIME}\"`);
26
+ }
27
+
28
+ const loadRepoSearchRuntime: RuntimeLoader = async () => import(REPO_SEARCH_CONTRACT_RUNTIME) as Promise<RepoSearchContractRuntime>;
29
+
30
+ /** Session-scoped optional Plastic policy registration when pi-repo-search is installed. */
31
+ export function createPlasticRepositoryProviderExtension(loadRuntime: RuntimeLoader = loadRepoSearchRuntime): (pi: ExtensionAPI) => void {
32
+ return (pi) => {
33
+ const registrations = new WeakMap<object, ScopeRegistrations>();
34
+ const unregisterScope = (current: ScopeRegistrations | undefined): void => {
35
+ if (current !== undefined) current.registry.unregister(current.policyToken);
36
+ };
37
+
38
+ pi.on("session_start", async (_event, ctx) => {
39
+ let runtime: RepoSearchContractRuntime;
40
+ try {
41
+ runtime = await loadRuntime();
42
+ } catch (error) {
43
+ if (isMissingRepoSearchRuntime(error)) return;
44
+ throw error;
45
+ }
46
+ if (typeof runtime.createRepositoryPolicyRegistryV1 !== "function") return;
47
+ const registry = runtime.createRepositoryPolicyRegistryV1();
48
+ const owner = await loadPlasticOwnerV1(import.meta.url);
49
+ const scope = ctx.sessionManager;
50
+ unregisterScope(registrations.get(scope));
51
+ const policyToken = registry.register(scope, createPlasticRepositorySearchPolicyV1(owner));
52
+ registrations.set(scope, Object.freeze({ policyToken, registry }));
53
+ });
54
+ pi.on("session_shutdown", (_event, ctx) => {
55
+ const scope = ctx.sessionManager;
56
+ const current = registrations.get(scope);
57
+ if (current === undefined) return;
58
+ unregisterScope(current);
59
+ registrations.delete(scope);
60
+ });
61
+ };
62
+ }
63
+
64
+ export default function registerPlasticRepositoryProviders(pi: ExtensionAPI): void {
65
+ createPlasticRepositoryProviderExtension()(pi);
66
+ }
@@ -0,0 +1,67 @@
1
+ export const WRAPPED_COMMAND_PATTERNS = [
2
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:bash|sh|zsh)(?:\.exe)?\s+-lc\s+(["'])([\s\S]*)\1$/i,
3
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:bash|sh|zsh)(?:\.exe)?\s+-c\s+(["'])([\s\S]*)\1$/i,
4
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cmd(?:\.exe)?\s+\/[ck]\s+(["'])([\s\S]*)\1$/i,
5
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cmd(?:\.exe)?\s+\/[ck]\s+()([\s\S]+)$/i,
6
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)?\s+-command\s+(["'])([\s\S]*)\1$/i,
7
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)?\s+-command\s+()([\s\S]+)$/i,
8
+ ];
9
+
10
+ export function splitSegments(command: string): string[] {
11
+ return command
12
+ .split(/&&|\|\||[;&|\r\n]/g)
13
+ .map((part) => part.trim())
14
+ .filter(Boolean);
15
+ }
16
+
17
+ export function stripAssignments(segment: string): string {
18
+ return segment.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*/, "").trim();
19
+ }
20
+
21
+ export function stripLeadingWrappers(segment: string): string {
22
+ let value = segment.trim();
23
+
24
+ while (true) {
25
+ const next = value
26
+ .replace(/^sudo\s+/i, "")
27
+ .replace(/^command\s+/i, "")
28
+ .replace(/^time\s+/i, "")
29
+ .replace(/^nice(?:\s+(?:-n\s+)?-?\d+)?\s+/i, "");
30
+
31
+ if (next !== value) {
32
+ value = next.trim();
33
+ continue;
34
+ }
35
+
36
+ const envMatch = value.match(/^env\s+([\s\S]*)$/i);
37
+ if (envMatch) {
38
+ let rest = String(envMatch[1] ?? "").trim();
39
+ rest = rest.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*/, "").trim();
40
+ value = rest;
41
+ continue;
42
+ }
43
+
44
+ break;
45
+ }
46
+
47
+ return value;
48
+ }
49
+
50
+ export function commandMatches(command: string, matcher: (segment: string) => boolean, depth = 0): boolean {
51
+ if (depth > 2) return false;
52
+
53
+ const segments = splitSegments(command);
54
+ for (const segment of segments) {
55
+ if (matcher(segment)) return true;
56
+
57
+ for (const pattern of WRAPPED_COMMAND_PATTERNS) {
58
+ const match = segment.match(pattern);
59
+ if (!match) continue;
60
+ const wrappedCommand = String(match[2] ?? "").trim();
61
+ if (!wrappedCommand) continue;
62
+ if (commandMatches(wrappedCommand, matcher, depth + 1)) return true;
63
+ }
64
+ }
65
+
66
+ return false;
67
+ }