diff --git a/README.md b/README.md index 0e3e9be..9e67ca9 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,9 @@ You can also use the shipped [`dcp.schema.json`](dcp.schema.json) for editor too - `nudgeNotification` — notification verbosity: `"off"`, `"minimal"`, or `"detailed"`. - `nudgeNotificationType` — notification delivery: `"toast"` or `"status"`. - `protectedFilePatterns` — file-path globs whose related tool outputs should never be pruned. + +Protected tool and file patterns use Node's `path.posix.matchesGlob` semantics: `/` is the path separator, and supported patterns include `*`, `**`, `?`, and character classes such as `[abc]` and `[0-9]`. Wildcards continue to match leading-dot path segments for compatibility with earlier pi-dcp releases. + - `turnProtection` — hard-protect the newest N raw user-message turns from every DCP transformation; defaults to `0`. ### `compress` @@ -170,7 +173,7 @@ You can also use the shipped [`dcp.schema.json`](dcp.schema.json) for editor too - `nudgeFrequency` — minimum messages between non-urgent nudges. - `iterationNudgeThreshold` — assistant iterations without user input before an iteration nudge fires. - `nudgeForce` — nudge strength: `"soft"` or `"strong"`. -- `protectedTools` — tool outputs preserved during compression. +- `protectedTools` — Node glob patterns for tool outputs preserved during compression. - `protectUserMessages` — append user message text to compression summaries. - `protectTags` — preserve `...` tag content in summaries. - `summaryBuffer` — exclude active summary tokens from threshold comparison to prevent cascading compressions. @@ -183,11 +186,11 @@ You can also use the shipped [`dcp.schema.json`](dcp.schema.json) for editor too ### `strategies` - `deduplication.enabled` — enable or disable deduplication. -- `deduplication.protectedTools` — tool names excluded from deduplication. +- `deduplication.protectedTools` — Node glob patterns for tool names excluded from deduplication. - `deduplication.turnProtection` — legacy deduplication window; deduplication uses the larger of this and top-level `turnProtection`. - `purgeErrors.enabled` — enable or disable stale failed-input purging. - `purgeErrors.turns` — age threshold for failed tool-input purging. -- `purgeErrors.protectedTools` — tool names excluded from failed-input purging. +- `purgeErrors.protectedTools` — Node glob patterns for tool names excluded from failed-input purging. DCP counts turns from raw user messages, not assistant iterations. When the history contains fewer user turns than `turnProtection`, all existing user turns are protected. Deduplication, stale-error pruning, `dcp:sweep`, and both compression modes enforce this boundary. Normal compression expands a tool target to its complete assistant call/result group; DCP removes orphan results it creates, while Pi synthesizes error results for assistant calls that have no result. diff --git a/dcp.schema.json b/dcp.schema.json index 2bfb066..78c556a 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -52,7 +52,7 @@ "type": "string" }, "default": [], - "description": "Glob patterns for file paths to protect from pruning" + "description": "Node path.posix.matchesGlob patterns for file paths to protect from pruning" }, "turnProtection": { "type": "integer", @@ -192,7 +192,7 @@ "type": "string" }, "default": [], - "description": "Tool outputs to preserve during compression (glob patterns)" + "description": "Tool outputs to preserve during compression (Node path.posix.matchesGlob patterns)" }, "protectUserMessages": { "type": "boolean", @@ -252,7 +252,7 @@ "type": "string" }, "default": [], - "description": "Tool names excluded from deduplication (glob patterns)" + "description": "Tool names excluded from deduplication (Node path.posix.matchesGlob patterns)" }, "turnProtection": { "type": "number", @@ -282,7 +282,7 @@ "type": "string" }, "default": [], - "description": "Tool names excluded from failed-input purging (glob patterns)" + "description": "Tool names excluded from failed-input purging (Node path.posix.matchesGlob patterns)" } } } diff --git a/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-03-native-glob-semantics.md b/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-03-native-glob-semantics.md index d5290ef..5d05650 100644 --- a/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-03-native-glob-semantics.md +++ b/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-03-native-glob-semantics.md @@ -2,19 +2,23 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Replace the limited hand-written glob compiler with Node 24 native matching so protected patterns support character classes and retain existing wildcard behavior. +**Goal:** Replace the limited hand-written glob compiler with Node's native POSIX glob matcher so every protected tool and file pattern supports character classes while preserving the existing slash-based contract. -**Architecture:** Delegate matching to `node:path.matchesGlob` and route every tool pattern through it. Keep the public `matchesGlob()` wrapper so callers and tests remain stable; update schema and README to state the native contract. +**Architecture:** Keep the public `matchesGlob()` wrapper and delegate it to `node:path.posix.matchesGlob`. The wrapper validates malformed character classes, converts matcher errors into a non-match, and retains the previous wildcard behavior for leading-dot path segments through a bounded compatibility fallback. Route all protected-tool consumers through `isToolNameProtected()`, and keep protected-file checks in the shared matcher path. Update the schema and README to state the native POSIX contract. -**Tech Stack:** TypeScript ESM, Node.js >=24.15.0 `path.matchesGlob`, Vitest. +**Tech Stack:** TypeScript ESM, Node.js >=24.15.0 `path.posix.matchesGlob`, Vitest. **Spec:** `docs/superpowers/specs/2026-08-22-dcp-troubleshooting-design.md` ## Global Constraints - Add no dependency. -- Preserve existing exact, `*`, `**`, `?`, and slash behavior. +- Require Node.js >=24.15.0 for implementation and verification; do not treat the current Node 23 runtime as supported evidence. +- Preserve existing exact, `*`, `**`, `?`, and `/` separator behavior. Use `path.posix.matchesGlob` so behavior does not change with the host OS; backslashes remain literal path text. +- Preserve the previous wildcard behavior for leading-dot path segments; native character classes and other native syntax must still use Node semantics. - Malformed string patterns must return a non-match rather than crash the context pipeline. +- Evaluate every configured pattern, including exact strings and patterns without `*` or `?`. +- Apply the contract consistently to deduplication, failed-input purging, compression-summary preservation, `dcp:sweep`, and protected file paths. - Treat broader native syntax as an intentional configuration contract change. --- @@ -22,13 +26,27 @@ ### Task 1: Lock down native behavior with failing tests **Files:** + +- Modify: `tests/protected-content.test.ts` - Modify: `tests/protected-patterns.test.ts` +- Modify: `tests/strategy-runner.test.ts` **Interfaces:** -- Consumes: `matchesGlob()`, `isToolNameProtected()`, and `isFilePathProtected()`. -- Produces: tests for character classes, literal regex characters, malformed patterns, and tool patterns without `*` or `?`. -- [ ] **Step 1: Add character-class and safety tests** +- Consumes: `matchesGlob()`, `isToolNameProtected()`, `isFilePathProtected()`, `appendProtectedToolOutputs()`, and `sweepAll()`. +- Produces: regression coverage for native character classes, literal regex punctuation, malformed patterns, POSIX separator behavior, leading-dot wildcard compatibility, compression protected-tool patterns, sweep protected-tool patterns, and sweep protected-file patterns. + +- [ ] **Step 1: Verify the supported runtime before running red tests** + +Run: + +```bash +node --version +``` + +Continue only when the version is `>=24.15.0`. The repository's `package.json` already declares this engine requirement. + +- [ ] **Step 2: Add matcher and consumer tests** Add inside `describe("matchesGlob")`: @@ -39,12 +57,25 @@ it("matches character classes using Node glob semantics", () => { expect(matchesGlob("file7.ts", "file[0-9].ts")).toBe(true); }); +it("preserves slash separators independently of the host OS", () => { + expect(matchesGlob("src/config.ts", "src/**/*.ts")).toBe(true); + expect(matchesGlob("src\\config.ts", "src/**/*.ts")).toBe(false); +}); + +it("preserves wildcard matching of leading-dot segments", () => { + expect(matchesGlob(".env", "*")).toBe(true); + expect(matchesGlob("src/.env", "src/**/*")).toBe(true); + expect(matchesGlob(".git/config", "**/*")).toBe(true); +}); + it("preserves regex punctuation as literal path text", () => { expect(matchesGlob("src/a+b.ts", "src/a+b.ts")).toBe(true); expect(matchesGlob("src/ab.ts", "src/a+b.ts")).toBe(false); }); -it("returns false for malformed character classes", () => { +it("returns false for malformed patterns", () => { + expect(matchesGlob("[", "[")).toBe(false); + expect(matchesGlob("src/[abc.ts", "src/[abc.ts")).toBe(false); expect(matchesGlob("testa.ts", "test[abc.ts")).toBe(false); expect(matchesGlob("foo", "[")).toBe(false); }); @@ -54,87 +85,124 @@ Add inside `describe("isToolNameProtected")`: ```typescript it("evaluates character-class patterns without star or question mark", () => { - expect(isToolNameProtected("read", ["r[ea]ad", "r[ea][ad]"])).toBe(true); - expect(isToolNameProtected("write", ["r[ea][ad]"])).toBe(false); + expect(isToolNameProtected("read", ["r[ea]ad"])).toBe(true); + expect(isToolNameProtected("write", ["r[ea]ad"])).toBe(false); }); ``` -- [ ] **Step 2: Run the tests and verify current limitations** +Add a character-class case to `describe("isFilePathProtected")`, such as `src/[ab].ts` matching `src/a.ts`. + +Add to `tests/protected-content.test.ts`: + +- a protected tool output matched by a class or wildcard pattern, proving compression summaries no longer use literal-only membership; +- the existing exact-match and error-result behavior unchanged. + +Add to `tests/strategy-runner.test.ts` under `describe("sweepAll")`: + +- a `config.compress.protectedTools` class/wildcard pattern that protects the matching tool while an unrelated completed tool is pruned; +- a `config.protectedFilePatterns` pattern that protects a completed tool whose parameters contain a matching `filePath`. + +- [ ] **Step 3: Run the focused tests and verify they fail for the intended current reasons** Run: ```bash -pnpm vitest run tests/protected-patterns.test.ts +pnpm vitest run tests/protected-patterns.test.ts tests/protected-content.test.ts tests/strategy-runner.test.ts ``` -Expected: FAIL on character-class matching and `isToolNameProtected` class evaluation. +Expected red failures: + +- character-class matching and class-based tool/file protection; +- compression protected-tool patterns; +- `sweepAll` protected-tool patterns; +- `sweepAll` protected-file patterns. + +The malformed-pattern tests may already pass against the current compiler; they remain required to lock down the safety contract. -### Task 2: Replace the custom compiler with the standard library +### Task 2: Replace the compiler and route every consumer through it **Files:** + +- Modify: `src/compress/protected-content.ts` - Modify: `src/strategies/protected-patterns.ts` +- Modify: `src/strategies/runner.ts` **Interfaces:** -- Consumes: Node's `matchesGlob(path: string, pattern: string): boolean`. -- Produces: existing exported `matchesGlob(input, pattern)` wrapper. -- [ ] **Step 1: Replace `globToRegex`** +- Consumes: Node's `posix.matchesGlob(path: string, pattern: string): boolean`. +- Produces: the existing exported `matchesGlob(input, pattern)` wrapper and consistent protected-pattern behavior across all pruning/preservation paths. + +- [ ] **Step 1: Replace the primary compiler with the POSIX native matcher** -At the top of `src/strategies/protected-patterns.ts`, add: +In `src/strategies/protected-patterns.ts`: ```typescript -import { matchesGlob as matchesPathGlob } from "node:path"; +import { posix } from "node:path"; ``` -Replace the current `matchesGlob()` and delete `globToRegex()`: +Remove the old compiler as the primary matcher, keep the exported wrapper, and make native errors safe: ```typescript export function matchesGlob(input: string, pattern: string): boolean { - return matchesPathGlob(input, pattern); + try { + return posix.matchesGlob(input, pattern); + } catch { + return false; + } } ``` -- [ ] **Step 2: Route every tool pattern through the matcher** +- Replace `isToolNameProtected()` with a `.some()` over every configured pattern. +- Leave `isFilePathProtected()` on the shared `matchesGlob()` wrapper. +- Add a bounded `matchesLegacyDotPath()` fallback after the native matcher returns false. Use it only when the input contains a leading-dot path segment and the pattern contains no native character-class, brace, or escape syntax; this preserves the old `*`/`**` behavior without overriding native class semantics. -Replace `isToolNameProtected()` with: +Do not use host-dependent `path.matchesGlob` or add path normalization in this helper. Pi's reference implementation uses `path.posix.matchesGlob` for stable slash semantics; its extra relative-path fallback is specific to Pi's guest `find` implementation and does not belong here. -```typescript -export function isToolNameProtected(toolName: string, protectedPatterns: string[]): boolean { - return protectedPatterns.some((pattern) => matchesGlob(toolName, pattern)); -} -``` +- [ ] **Step 2: Route compression-summary preservation through the shared matcher** + +In `src/compress/protected-content.ts`, import `isToolNameProtected()` and replace `protectedTools.includes(msg.toolName)` with the shared predicate. Keep the existing error-result exclusion and output formatting unchanged. + +- [ ] **Step 3: Route `dcp:sweep` through the shared tool and file predicates** + +In `src/strategies/runner.ts`: -Exact strings continue to work under native matching; no special branch is needed. +- replace the `Set` membership check in `sweepAll()` with `isToolNameProtected(entry.tool, protectedTools)`; +- retain `BASE_PROTECTED_TOOLS` and `config.compress.protectedTools` as the inputs to that predicate; +- use `getFilePathsFromParameters()` and `isFilePathProtected()` before pruning so `protectedFilePatterns` keeps its documented “never pruned” contract during sweep; +- preserve status, turn-protection, statistics, and token-counter behavior. -- [ ] **Step 3: Run focused tests** +- [ ] **Step 4: Run the focused tests green** Run: ```bash -pnpm vitest run tests/protected-patterns.test.ts +pnpm vitest run tests/protected-patterns.test.ts tests/protected-content.test.ts tests/strategy-runner.test.ts ``` -Expected: PASS. +Expected: all focused tests pass, including the new class, malformed-pattern, compression, sweep-tool, and sweep-file cases. ### Task 3: Publish the explicit configuration contract **Files:** + +- Regenerate: `dcp.schema.json` - Modify: `src/config-schema.ts` - Modify: `README.md` **Interfaces:** + - Consumes: protected tool and file pattern descriptions. -- Produces: documentation that patterns follow Node native glob semantics. +- Produces: a consistent public contract that identifies Node POSIX glob semantics. - [ ] **Step 1: Update schema descriptions** Use these exact descriptions in `src/config-schema.ts`: ```typescript -"Tool names excluded from deduplication (Node path.matchesGlob patterns)" -"Tool names excluded from failed-input purging (Node path.matchesGlob patterns)" -"Tool outputs to preserve during compression (Node path.matchesGlob patterns)" -"Node path.matchesGlob patterns for file paths to protect from pruning" +"Tool names excluded from deduplication (Node path.posix.matchesGlob patterns)"; +"Tool names excluded from failed-input purging (Node path.posix.matchesGlob patterns)"; +"Tool outputs to preserve during compression (Node path.posix.matchesGlob patterns)"; +"Node path.posix.matchesGlob patterns for file paths to protect from pruning"; ``` - [ ] **Step 2: Update README configuration text** @@ -142,43 +210,63 @@ Use these exact descriptions in `src/config-schema.ts`: After the `protectedFilePatterns` bullet, add: ```markdown -Protected tool and file patterns use Node's `path.matchesGlob` semantics, including `*`, `**`, `?`, and character classes such as `[abc]` and `[0-9]`. +Protected tool and file patterns use Node's `path.posix.matchesGlob` semantics: `/` is the path separator, and supported patterns include `*`, `**`, `?`, and character classes such as `[abc]` and `[0-9]`. Wildcards continue to match leading-dot path segments for compatibility with earlier pi-dcp releases. ``` -Change each protected-tool bullet that says only “tool names” to say “Node glob patterns for tool names” without duplicating the syntax list. +Describe all three protected-tool settings consistently: -- [ ] **Step 3: Run focused and schema checks** +- `compress.protectedTools` — Node glob patterns for tool outputs preserved during compression. +- `deduplication.protectedTools` — Node glob patterns for tool names excluded from deduplication. +- `purgeErrors.protectedTools` — Node glob patterns for tool names excluded from failed-input purging. -Run: +Do not duplicate the syntax list in each bullet. -```bash -pnpm vitest run tests/protected-patterns.test.ts -pnpm typecheck -pnpm exec tsx scripts/generate-schema.ts > /tmp/pi-dcp-schema.json -diff -u dcp.schema.json /tmp/pi-dcp-schema.json || true -``` +- [ ] **Step 3: Regenerate and inspect the tracked schema** -Review the schema diff. If repository policy expects generated schema changes in feature commits, regenerate with: +Run: ```bash pnpm run generate:schema +git diff -- dcp.schema.json ``` -Then run: +The schema diff must contain only the four protected-pattern description changes. Keep `dcp.schema.json` in the feature change; its tracked history already updates it with `src/config-schema.ts` changes. + +### Task 4: Verify the complete change + +- [ ] **Step 1: Run repository checks on the supported Node runtime** + +Run: ```bash -pnpm format:check -pnpm lint +pnpm check +pnpm run pack:verify git diff --check ``` -Expected: all checks PASS. Any generated schema diff must contain only the four description changes. +Expected: all checks pass, package verification succeeds, and no generated or whitespace-only changes remain outside the intended files. + +- [ ] **Step 2: Review the final diff** -- [ ] **Step 4: Commit Phase 3** +Confirm the diff is limited to: + +```text +src/strategies/protected-patterns.ts +src/compress/protected-content.ts +src/strategies/runner.ts +tests/protected-patterns.test.ts +tests/protected-content.test.ts +tests/strategy-runner.test.ts +src/config-schema.ts +README.md +dcp.schema.json +``` + +Confirm no dependency, general-purpose replacement glob compiler, host-dependent matcher, or unrelated sweep behavior was added. The only compatibility fallback should be the bounded leading-dot wildcard path described in Task 2. + +- [ ] **Step 3: Commit Phase 3** ```bash -git add src/strategies/protected-patterns.ts tests/protected-patterns.test.ts src/config-schema.ts README.md dcp.schema.json +git add src/strategies/protected-patterns.ts src/compress/protected-content.ts src/strategies/runner.ts tests/protected-patterns.test.ts tests/protected-content.test.ts tests/strategy-runner.test.ts src/config-schema.ts README.md dcp.schema.json git commit -m "feat: use native glob matching for protected patterns" ``` - -If `dcp.schema.json` is unchanged, omit it from `git add`. diff --git a/src/compress/protected-content.ts b/src/compress/protected-content.ts index b5fddca..64aa8c0 100644 --- a/src/compress/protected-content.ts +++ b/src/compress/protected-content.ts @@ -1,5 +1,6 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { DcpConfig } from "../config.ts"; +import { isToolNameProtected } from "../strategies/protected-patterns.ts"; const PROTECT_TAG_PATTERN = /([\s\S]*?)<\/protect>/gi; @@ -86,7 +87,7 @@ export function appendProtectedToolOutputs( for (const msg of messages) { if (msg.role !== "toolResult") continue; if (msg.isError) continue; - if (!protectedTools.includes(msg.toolName)) continue; + if (!isToolNameProtected(msg.toolName, protectedTools)) continue; const text = getMessageText(msg); if (text.trim()) { diff --git a/src/config-schema.ts b/src/config-schema.ts index 446c098..7015adc 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -7,7 +7,7 @@ export const DeduplicationConfigSchema = Type.Object({ }), protectedTools: Type.Array(Type.String(), { default: [], - description: "Tool names excluded from deduplication (glob patterns)", + description: "Tool names excluded from deduplication (Node path.posix.matchesGlob patterns)", }), turnProtection: Type.Number({ default: 0, @@ -29,7 +29,8 @@ export const PurgeErrorsConfigSchema = Type.Object({ }), protectedTools: Type.Array(Type.String(), { default: [], - description: "Tool names excluded from failed-input purging (glob patterns)", + description: + "Tool names excluded from failed-input purging (Node path.posix.matchesGlob patterns)", }), }); @@ -94,7 +95,8 @@ export const CompressConfigSchema = Type.Object({ }), protectedTools: Type.Array(Type.String(), { default: [], - description: "Tool outputs to preserve during compression (glob patterns)", + description: + "Tool outputs to preserve during compression (Node path.posix.matchesGlob patterns)", }), protectUserMessages: Type.Boolean({ default: false, @@ -159,7 +161,7 @@ export const DcpConfigSchema = Type.Object({ }), protectedFilePatterns: Type.Array(Type.String(), { default: [], - description: "Glob patterns for file paths to protect from pruning", + description: "Node path.posix.matchesGlob patterns for file paths to protect from pruning", }), turnProtection: Type.Integer({ default: 0, diff --git a/src/strategies/protected-patterns.ts b/src/strategies/protected-patterns.ts index 6b60bf0..b0c21d3 100644 --- a/src/strategies/protected-patterns.ts +++ b/src/strategies/protected-patterns.ts @@ -1,17 +1,38 @@ -/** - * Glob matching and tool/file path protection for pruning strategies. - * - * Custom glob implementation (no external dependency) supporting: - * - `*` matches any chars except `/` - * - `**` matches any chars including `/` - * - `?` matches single char except `/` - */ +import { posix } from "node:path"; +/** Glob matching and tool/file path protection for pruning strategies. */ export function matchesGlob(input: string, pattern: string): boolean { - return globToRegex(pattern).test(input); + if (hasUnclosedCharacterClass(pattern)) return false; + + try { + if (posix.matchesGlob(input, pattern)) return true; + } catch { + return false; + } + + // Node's matcher excludes leading-dot segments from wildcards; preserve the + // previous matcher contract without changing native class semantics. + return matchesLegacyDotPath(input, pattern); } -function globToRegex(pattern: string): RegExp { +function hasUnclosedCharacterClass(pattern: string): boolean { + let open = false; + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] === "\\") { + i++; + } else if (pattern[i] === "[") { + open = true; + } else if (pattern[i] === "]") { + open = false; + } + } + return open; +} + +function matchesLegacyDotPath(input: string, pattern: string): boolean { + if (!input.split("/").some((segment) => segment.startsWith("."))) return false; + if (["[", "]", "{", "}", "\\"].some((character) => pattern.includes(character))) return false; + let result = "^"; let i = 0; while (i < pattern.length) { @@ -27,31 +48,25 @@ function globToRegex(pattern: string): RegExp { } } else { result += "[^/]*"; - i += 1; + i++; } } else if (c === "?") { result += "[^/]"; - i += 1; - } else if (".+^${}()|[]\\".includes(c)) { - result += "\\" + c; - i += 1; + i++; + } else if ((".+^$" + "{}()|[]\\").includes(c)) { + result += `\\${c}`; + i++; } else { result += c; - i += 1; + i++; } } - result += "$"; - return new RegExp(result); + + return new RegExp(`${result}$`).test(input); } export function isToolNameProtected(toolName: string, protectedPatterns: string[]): boolean { - for (const pattern of protectedPatterns) { - if (pattern === toolName) return true; - if (pattern.includes("*") || pattern.includes("?")) { - if (matchesGlob(toolName, pattern)) return true; - } - } - return false; + return protectedPatterns.some((pattern) => matchesGlob(toolName, pattern)); } export function getFilePathsFromParameters( diff --git a/src/strategies/runner.ts b/src/strategies/runner.ts index 4f27944..b24189a 100644 --- a/src/strategies/runner.ts +++ b/src/strategies/runner.ts @@ -128,7 +128,7 @@ export function runStrategies(state: SessionState, config: DcpConfig): StrategyR * Used by the dcp:sweep command. */ export function sweepAll(state: SessionState, config: DcpConfig): StrategyResult { - const protectedTools = new Set([...BASE_PROTECTED_TOOLS, ...config.compress.protectedTools]); + const protectedTools = [...BASE_PROTECTED_TOOLS, ...config.compress.protectedTools]; let pruned = 0; let tokensSaved = 0; @@ -136,8 +136,14 @@ export function sweepAll(state: SessionState, config: DcpConfig): StrategyResult for (const [toolCallId, entry] of state.toolParameters) { if (state.prune.tools.has(toolCallId)) continue; - if (protectedTools.has(entry.tool)) continue; + if (isToolNameProtected(entry.tool, protectedTools)) continue; if (entry.status !== "completed") continue; + + const filePaths = getFilePathsFromParameters( + entry.tool, + entry.parameters as Record, + ); + if (isFilePathProtected(filePaths, config.protectedFilePatterns)) continue; if ( config.turnProtection > 0 && state.currentUserTurn - entry.userTurn < config.turnProtection diff --git a/tests/protected-content.test.ts b/tests/protected-content.test.ts index 68fd531..c691981 100644 --- a/tests/protected-content.test.ts +++ b/tests/protected-content.test.ts @@ -97,6 +97,14 @@ describe("appendProtectedToolOutputs", () => { expect(result).toContain("file content here"); }); + it("preserves output when a protected-tool glob matches the name", () => { + const messages: AgentMessage[] = [makeToolResultMessage("call1", "read", "file content here")]; + + const result = appendProtectedToolOutputs("Summary", messages, ["r[ea]ad"]); + expect(result).toContain("[Protected Tool Output: read]"); + expect(result).toContain("file content here"); + }); + it("does not append when tool name not in protectedTools", () => { const messages: AgentMessage[] = [makeToolResultMessage("call1", "grep", "grep output")]; diff --git a/tests/protected-patterns.test.ts b/tests/protected-patterns.test.ts index 97e881b..3eb4913 100644 --- a/tests/protected-patterns.test.ts +++ b/tests/protected-patterns.test.ts @@ -27,6 +27,35 @@ describe("protected-patterns", () => { expect(matchesGlob("ab", "a?")).toBe(true); expect(matchesGlob("abc", "a?")).toBe(false); }); + + it("matches character classes using Node glob semantics", () => { + expect(matchesGlob("testa.ts", "test[abc].ts")).toBe(true); + expect(matchesGlob("testz.ts", "test[abc].ts")).toBe(false); + expect(matchesGlob("file7.ts", "file[0-9].ts")).toBe(true); + }); + + it("preserves slash separators independently of the host OS", () => { + expect(matchesGlob("src/config.ts", "src/**/*.ts")).toBe(true); + expect(matchesGlob("src\\config.ts", "src/**/*.ts")).toBe(false); + }); + + it("preserves regex punctuation as literal path text", () => { + expect(matchesGlob("src/a+b.ts", "src/a+b.ts")).toBe(true); + expect(matchesGlob("src/ab.ts", "src/a+b.ts")).toBe(false); + }); + + it("returns false for malformed patterns", () => { + expect(matchesGlob("[", "[")).toBe(false); + expect(matchesGlob("src/[abc.ts", "src/[abc.ts")).toBe(false); + expect(matchesGlob("testa.ts", "test[abc.ts")).toBe(false); + expect(matchesGlob("foo", "[")).toBe(false); + }); + + it("preserves wildcard matching of leading-dot segments", () => { + expect(matchesGlob(".env", "*")).toBe(true); + expect(matchesGlob("src/.env", "src/**/*")).toBe(true); + expect(matchesGlob(".git/config", "**/*")).toBe(true); + }); }); describe("isToolNameProtected", () => { @@ -40,6 +69,11 @@ describe("protected-patterns", () => { expect(isToolNameProtected("other", ["todo*"])).toBe(false); }); + it("evaluates character-class patterns without star or question mark", () => { + expect(isToolNameProtected("read", ["r[ea]ad"])).toBe(true); + expect(isToolNameProtected("write", ["r[ea]ad"])).toBe(false); + }); + it("returns false for empty patterns", () => { expect(isToolNameProtected("bash", [])).toBe(false); }); @@ -65,6 +99,11 @@ describe("protected-patterns", () => { expect(isFilePathProtected(["lib/foo.ts"], ["src/**/*.ts"])).toBe(false); }); + it("matches file paths against character-class patterns", () => { + expect(isFilePathProtected(["src/a.ts"], ["src/[ab].ts"])).toBe(true); + expect(isFilePathProtected(["src/c.ts"], ["src/[ab].ts"])).toBe(false); + }); + it("returns false for empty paths or patterns", () => { expect(isFilePathProtected([], ["src/**"])).toBe(false); expect(isFilePathProtected(["/tmp/a"], [])).toBe(false); diff --git a/tests/strategy-runner.test.ts b/tests/strategy-runner.test.ts index 41855b9..71d081f 100644 --- a/tests/strategy-runner.test.ts +++ b/tests/strategy-runner.test.ts @@ -503,6 +503,65 @@ describe("sweepAll", () => { expect(state.prune.tools.has("a2")).toBe(true); }); + it("preserves completed tools matched by protected-tool glob patterns", () => { + const state = createSessionState(); + const config = makeDefaultConfig({ protectedTools: ["custom_[tu]ool"] }); + + seedToolCache(state, [ + { + id: "a1", + tool: "custom_tool", + parameters: { path: "/a.ts" }, + status: "completed", + userTurn: 1, + tokenCount: 100, + }, + { + id: "a2", + tool: "another_tool", + parameters: { path: "/b.ts" }, + status: "completed", + userTurn: 2, + tokenCount: 200, + }, + ]); + + const result = sweepAll(state, config); + expect(result.pruned).toBe(1); + expect(state.prune.tools.has("a1")).toBe(false); + expect(state.prune.tools.has("a2")).toBe(true); + }); + + it("preserves completed tools operating on protected file paths", () => { + const state = createSessionState(); + const config = makeDefaultConfig(); + config.protectedFilePatterns = ["src/[ab].ts"]; + + seedToolCache(state, [ + { + id: "a1", + tool: "custom_tool", + parameters: { filePath: "src/a.ts" }, + status: "completed", + userTurn: 1, + tokenCount: 100, + }, + { + id: "a2", + tool: "another_tool", + parameters: { filePath: "src/c.ts" }, + status: "completed", + userTurn: 2, + tokenCount: 200, + }, + ]); + + const result = sweepAll(state, config); + expect(result.pruned).toBe(1); + expect(state.prune.tools.has("a1")).toBe(false); + expect(state.prune.tools.has("a2")).toBe(true); + }); + it("skips already-pruned tools", () => { const state = createSessionState(); const config = makeDefaultConfig();