From 72aa872faaed4544ce62eb286131e6b62c344c96 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Fri, 16 Oct 2026 14:31:06 +0300 Subject: [PATCH 1/9] fix(workspace): glob and grep's glob filter take a mount-prefixed pattern Every workspace path is mount-prefixed, glob's own results included, but the pattern was matched only against paths relative to the mount (or to `path`), so `notes/diary/2026-10-13.md` answered "no matches" for a file that was there. When a pattern's first segment names a mount, its rest is now also matched within that mount; a pattern that names no mount matches as before, including a relative one whose first directory shares a mount's name. grep's `glob` filter takes the same two readings. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.fixed.md | 10 ++ src/modules/workspace/index.ts | 32 +++++-- test/workspace-glob-mount-prefix.test.ts | 95 +++++++++++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 changelog.d/glob-mount-prefixed-patterns.fixed.md create mode 100644 test/workspace-glob-mount-prefix.test.ts diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md new file mode 100644 index 000000000..414e798f9 --- /dev/null +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -0,0 +1,10 @@ +- `workspace--glob` finds files from a mount-prefixed pattern, such as + `notes/diary/2026-10-13.md` or `project/src/**/*.ts`, as every other + workspace path is written and as its own results are returned. The pattern + was matched only against paths relative to the mount (or to `path`), so a + mount-prefixed one answered "no matches" for files that were there. When a + pattern's first segment names a mount, its rest is now also matched within + that mount; patterns that name no mount match exactly as before, including + a relative one whose first directory happens to share a mount's name. + `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same + way. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 3be26091f..8f219a0f5 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1167,11 +1167,12 @@ export class WorkspaceModule implements Module { }, { name: 'glob', - description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.', + description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes. ' + + 'The pattern is relative to path (or to each mount\'s root), or mount-prefixed like the paths it returns.', inputSchema: { type: 'object' as const, properties: { - pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.ts")' }, + pattern: { type: 'string', description: 'Glob pattern, relative (e.g., "**/*.ts") or mount-prefixed (e.g., "project/src/**/*.ts")' }, path: { type: 'string', description: 'Directory to search in (mount-prefixed, optional)' }, }, required: ['pattern'], @@ -1189,7 +1190,7 @@ export class WorkspaceModule implements Module { properties: { pattern: { type: 'string', description: 'Regular expression pattern' }, path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' }, - glob: { type: 'string', description: 'Glob pattern to filter files' }, + glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts") or mount-prefixed (e.g., "project/src/**/*.ts")' }, contextBefore: { type: 'number', description: 'Context lines before match' }, contextAfter: { type: 'number', description: 'Context lines after match' }, }, @@ -2401,7 +2402,6 @@ export class WorkspaceModule implements Module { } private async handleGlob(input: GlobInput): Promise { - const regex = globToRegex(input.pattern); const matches: Array> = []; const incomplete: Array<{ path: string; reason: string }> = []; @@ -2411,11 +2411,12 @@ export class WorkspaceModule implements Module { : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' })); for (const { mount, relativePath } of mountsToSearch) { + const globs = mountGlobs(input.pattern, mount.config.name); const pass = await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true })); for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) { const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path; - if (regex.test(testPath)) { + if (globs.relative.test(testPath) || globs.prefixed?.test(report.path) === true) { matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) }); } } @@ -2444,7 +2445,6 @@ export class WorkspaceModule implements Module { return { success: false, error: `Invalid regex: ${input.pattern}`, isError: true }; } - const fileGlob = input.glob ? globToRegex(input.glob) : null; const contextBefore = input.contextBefore ?? 0; const contextAfter = input.contextAfter ?? 0; @@ -2507,8 +2507,9 @@ export class WorkspaceModule implements Module { } const intents = this.intents.get(mount.config.name)!; + const fileGlobs = input.glob ? mountGlobs(input.glob, mount.config.name) : null; for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) { - if (fileGlob && !fileGlob.test(report.path)) continue; + if (fileGlobs && !fileGlobs.relative.test(report.path) && fileGlobs.prefixed?.test(report.path) !== true) continue; const file = `${mount.config.name}/${report.path}`; if (report.state === 'disk-only') { skipped.push({ @@ -3167,6 +3168,23 @@ function stateView(report: PathReport): Record { // Utilities // ========================================================================== +/** + * The two ways a glob can be written for one mount. `relative` is the pattern + * as given, matched relative to the search root as it always was. `prefixed` + * is set when the pattern's first segment is this mount's name: every path + * the tools take and return is mount-prefixed, so `notes/diary/*.md` is the + * natural way to write a pattern, and tested as written against a + * mount-relative path it could never match — a glob answered "no matches" + * for files that were there. It matches the rest of the pattern against the + * mount-relative path. A pattern that names another mount, or none, matches + * as before. + */ +function mountGlobs(pattern: string, mountName: string): { relative: RegExp; prefixed: RegExp | null } { + const slash = pattern.indexOf('/'); + const prefixed = slash > 0 && pattern.slice(0, slash) === mountName ? globToRegex(pattern.slice(slash + 1)) : null; + return { relative: globToRegex(pattern), prefixed }; +} + /** * Convert a glob pattern to a RegExp. */ diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts new file mode 100644 index 000000000..9631b2e77 --- /dev/null +++ b/test/workspace-glob-mount-prefix.test.ts @@ -0,0 +1,95 @@ +/** + * glob and grep's glob filter take a mount-prefixed pattern, like every other + * workspace path. Before, the pattern was matched only against paths relative + * to the search root while results came back mount-prefixed, so + * `notes/diary/2026-10-13.md` answered "no matches" for a file that was there. + */ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { WorkspaceModule } from '../src/modules/workspace/index.js'; + +/** Two mounts; `project` has a subdirectory named like the other mount. Files + * are written straight to disk, as another process would. */ +function setup(t: TestContext) { + const root = mkdtempSync(join(tmpdir(), 'workspace-glob-prefix-')); + const dirs = { notes: join(root, 'notes'), project: join(root, 'project') }; + const files: Record = { + 'notes/README.md': 'notes readme', + 'notes/diary/2026-10-12.md': 'entry twelve', + 'notes/diary/2026-10-13.md': 'entry thirteen', + 'project/README.md': 'project readme', + 'project/docs/guide.md': 'guide entry', + 'project/notes/todo.md': 'todo entry', + 'project/src/main.ts': 'export const entry = 1;', + }; + for (const [path, content] of Object.entries(files)) { + const [mount, ...rest] = path.split('/'); + const full = join(dirs[mount as keyof typeof dirs], ...rest); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + const workspace = new WorkspaceModule({ + mounts: [ + { name: 'notes', path: dirs.notes, mode: 'read-write', watch: 'never' }, + { name: 'project', path: dirs.project, mode: 'read-write', watch: 'never' }, + ], + }); + workspace.initStore(store); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const glob = async (input: Record) => { + const r = await workspace.handleToolCall({ id: 'g', name: 'glob', input }); + assert.equal(r.success, true, JSON.stringify(r)); + return ((r.data as { matches: Array<{ path: string }> }).matches).map((m) => m.path).sort(); + }; + const grepFiles = async (input: Record) => { + const r = await workspace.handleToolCall({ id: 'r', name: 'grep', input }); + assert.equal(r.success, true, JSON.stringify(r)); + return ((r.data as { results: Array<{ file: string }> }).results).map((x) => x.file).sort(); + }; + return { glob, grepFiles }; +} + +test('a mount-prefixed pattern naming one file finds it', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: 'notes/diary/2026-10-13.md' }), ['notes/diary/2026-10-13.md']); +}); + +test('a mount-prefixed wildcard matches within that mount only', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); + assert.deepEqual(await glob({ pattern: 'project/**/*.md' }), ['project/docs/guide.md', 'project/notes/todo.md']); + assert.deepEqual(await glob({ pattern: 'project/*.md' }), ['project/README.md']); +}); + +test('relative patterns match as before, in every mount', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: '**/*.md' }), [ + 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'project/docs/guide.md', 'project/notes/todo.md', + ]); + assert.deepEqual(await glob({ pattern: '*.md' }), ['notes/README.md', 'project/README.md']); + // A pattern whose first segment is a mount's name keeps its relative + // reading too: project/notes/ still matches, beside the notes mount's root. + assert.deepEqual(await glob({ pattern: 'notes/*.md' }), ['notes/README.md', 'project/notes/todo.md']); +}); + +test('with a path, the pattern may be relative to it or mount-prefixed', async (t) => { + const { glob } = setup(t); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: '*-13.md' }), ['notes/diary/2026-10-13.md']); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/diary/*-13.md' }), ['notes/diary/2026-10-13.md']); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'project/**' }), []); +}); + +test("grep's glob filter takes a mount-prefixed pattern", async (t) => { + const { grepFiles } = setup(t); + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'project/src/*.ts' }), ['project/src/main.ts']); + // Relative filters are unchanged. + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: '**/*.md' }), [ + 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'project/docs/guide.md', 'project/notes/todo.md', + ]); +}); From d00f40e669935723bf02f9b5c35c157d653a30bc Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Sat, 17 Oct 2026 02:15:16 -0400 Subject: [PATCH 2/9] fix(workspace): a `**/` in a glob matches zero or more directories globPartToRegex turned `**` into `.*`, so a `**/` needed at least one directory before what followed it: `project/src/**/*.ts` missed `project/src/main.ts`, and `**/*.md` missed the `.md` files at a mount's root. Both tools' descriptions give `**/` patterns as examples, and other glob tools read `**/` as zero or more directories. globPartToRegex now converts each glob token in one pass, through a table: a `**/` at the start of a path segment becomes `(?:.*/)?`, any other `**` is still `.*`, and `*`, `?` and the characters a regex treats specially are converted as before. globToRegex tells each part whether it starts a path segment, so a `**/` right after a brace group, or at the start of an alternative that doesn't start a segment, still needs a directory there. The fragment says so. Its sentence on patterns that name no mount now says that every pattern is still matched relative to each mount as well, which "match exactly as before" no longer covered. Tests: the two expectations that pinned the old reading now include the root files. A new test covers a mount-prefixed and a relative `**/`, a `**/` under `path`, and grep's glob filter, with a file one directory deeper than `project/src/main.ts` in the fixture. Another covers a `**/` right after a brace group. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.fixed.md | 10 ++++-- src/modules/workspace/index.ts | 25 +++++++++------ test/workspace-glob-mount-prefix.test.ts | 31 ++++++++++++++++--- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md index 414e798f9..0c53dceec 100644 --- a/changelog.d/glob-mount-prefixed-patterns.fixed.md +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -4,7 +4,11 @@ was matched only against paths relative to the mount (or to `path`), so a mount-prefixed one answered "no matches" for files that were there. When a pattern's first segment names a mount, its rest is now also matched within - that mount; patterns that name no mount match exactly as before, including - a relative one whose first directory happens to share a mount's name. + that mount. Every pattern is still matched relative to each mount (or to + `path`) as well, so `notes/*.md` still finds `project/notes/todo.md` beside + the `notes` mount's own files. `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same - way. + way. In both, a `**/` now matches zero or more directories, as in other glob + tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and + `**/*.md` also finds the `.md` files at a mount's root; it used to need at + least one directory there. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 8f219a0f5..05c8c4dfd 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -3193,11 +3193,14 @@ function globToRegex(pattern: string): RegExp { let regex = ''; let i = 0; while (i < pattern.length) { + // Whether the part that starts here starts a path segment (see + // globPartToRegex). + const atSegmentStart = i === 0 || pattern[i - 1] === '/'; if (pattern[i] === '{') { const closeIdx = pattern.indexOf('}', i); if (closeIdx > i) { const alternatives = pattern.slice(i + 1, closeIdx).split(','); - regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')'; + regex += '(?:' + alternatives.map(a => globPartToRegex(a, atSegmentStart)).join('|') + ')'; i = closeIdx + 1; continue; } @@ -3209,17 +3212,21 @@ function globToRegex(pattern: string): RegExp { i++; } if (chunk) { - regex += globPartToRegex(chunk); + regex += globPartToRegex(chunk, atSegmentStart); } } return new RegExp(`^${regex}$`); } -function globPartToRegex(part: string): string { - return part - .replace(/[.+^$()|[\]\\]/g, '\\$&') - .replace(/\*\*/g, '__DOUBLESTAR__') - .replace(/\*/g, '[^/]*') - .replace(/__DOUBLESTAR__/g, '.*') - .replace(/\?/g, '[^/]'); +// What each glob token becomes. A `**/` at the start of a path segment matches +// zero or more whole directories, so `src/**/*.ts` also matches `src/main.ts`; +// any other `**` matches any run of characters. +const GLOB_TOKENS: Record = { '**/': '(?:.*/)?', '**': '.*', '*': '[^/]*', '?': '[^/]' }; + +// One part of a pattern, between brace groups or inside one. A part that +// doesn't start a path segment, such as one right after a brace group, reads +// a `**/` at its start as any run of characters and then a directory. +function globPartToRegex(part: string, atSegmentStart: boolean): string { + return part.replace(/(?<=^|\/)\*\*\/|\*\*|[*?]|[.+^$()|[\]\\]/g, (m, offset: number) => + m === '**/' && offset === 0 && !atSegmentStart ? '.*/' : GLOB_TOKENS[m] ?? `\\${m}`); } diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts index 9631b2e77..e603f2b61 100644 --- a/test/workspace-glob-mount-prefix.test.ts +++ b/test/workspace-glob-mount-prefix.test.ts @@ -3,6 +3,8 @@ * workspace path. Before, the pattern was matched only against paths relative * to the search root while results came back mount-prefixed, so * `notes/diary/2026-10-13.md` answered "no matches" for a file that was there. + * A `**` path segment matches zero or more directories, so a pattern for every + * `.ts` file under `project/src` also finds `project/src/main.ts`. */ import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; @@ -14,7 +16,7 @@ import { WorkspaceModule } from '../src/modules/workspace/index.js'; /** Two mounts; `project` has a subdirectory named like the other mount. Files * are written straight to disk, as another process would. */ -function setup(t: TestContext) { +function setup(t: TestContext, extra: Record = {}) { const root = mkdtempSync(join(tmpdir(), 'workspace-glob-prefix-')); const dirs = { notes: join(root, 'notes'), project: join(root, 'project') }; const files: Record = { @@ -25,6 +27,8 @@ function setup(t: TestContext) { 'project/docs/guide.md': 'guide entry', 'project/notes/todo.md': 'todo entry', 'project/src/main.ts': 'export const entry = 1;', + 'project/src/lib/util.ts': 'export const helper = 2;', + ...extra, }; for (const [path, content] of Object.entries(files)) { const [mount, ...rest] = path.split('/'); @@ -62,14 +66,15 @@ test('a mount-prefixed pattern naming one file finds it', async (t) => { test('a mount-prefixed wildcard matches within that mount only', async (t) => { const { glob } = setup(t); assert.deepEqual(await glob({ pattern: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); - assert.deepEqual(await glob({ pattern: 'project/**/*.md' }), ['project/docs/guide.md', 'project/notes/todo.md']); + assert.deepEqual(await glob({ pattern: 'project/**/*.md' }), ['project/README.md', 'project/docs/guide.md', 'project/notes/todo.md']); assert.deepEqual(await glob({ pattern: 'project/*.md' }), ['project/README.md']); }); -test('relative patterns match as before, in every mount', async (t) => { +test('relative patterns match relative to every mount', async (t) => { const { glob } = setup(t); assert.deepEqual(await glob({ pattern: '**/*.md' }), [ - 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'project/docs/guide.md', 'project/notes/todo.md', + 'notes/README.md', 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', + 'project/README.md', 'project/docs/guide.md', 'project/notes/todo.md', ]); assert.deepEqual(await glob({ pattern: '*.md' }), ['notes/README.md', 'project/README.md']); // A pattern whose first segment is a mount's name keeps its relative @@ -84,11 +89,27 @@ test('with a path, the pattern may be relative to it or mount-prefixed', async ( assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'project/**' }), []); }); +test('a `**/` matches zero or more directories, in a relative or a mount-prefixed pattern', async (t) => { + const { glob, grepFiles } = setup(t); + const sources = ['project/src/lib/util.ts', 'project/src/main.ts']; + assert.deepEqual(await glob({ pattern: 'project/src/**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: 'src/**/*.ts' }), sources); + assert.deepEqual(await glob({ path: 'project/src', pattern: '**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: '**/README.md' }), ['notes/README.md', 'project/README.md']); + assert.deepEqual(await grepFiles({ pattern: 'export', glob: 'project/src/**/*.ts' }), sources); +}); + +test('a `**/` right after a brace group still needs a directory there', async (t) => { + // Not the start of a path segment, so the `**` is any run of characters. + const { glob } = setup(t, { 'notes/drafts.md': 'drafts', 'notes/drafts/one.md': 'one' }); + assert.deepEqual(await glob({ pattern: 'notes/draft{s,ing}**/*.md' }), ['notes/drafts/one.md']); +}); + test("grep's glob filter takes a mount-prefixed pattern", async (t) => { const { grepFiles } = setup(t); assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'project/src/*.ts' }), ['project/src/main.ts']); - // Relative filters are unchanged. + // Relative filters still match relative to each mount. assert.deepEqual(await grepFiles({ pattern: 'entry', glob: '**/*.md' }), [ 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'project/docs/guide.md', 'project/notes/todo.md', ]); From 92092ab26cbcb42c1787bc55ed88c92d9801908e Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Sat, 17 Oct 2026 15:53:34 -0400 Subject: [PATCH 3/9] fix(workspace): grep builds its glob filter before its pass, so one it refuses is refused first Since #439, globToRegex refuses a brace group it can't read. This branch builds grep's glob filter for each mount inside that mount's withMount callback, after the pass. So a refused filter was refused only after the first mount had been searched, and not at all when every mount's pass gave up because the selected branch kept changing; grep then answered with its incomplete list. On main, the filter was built, and refused, before any mount was searched. It's now built before the pass, as glob builds its own pattern. #439's refusal tests pass either way: that the refusal now comes first is by reading. --- src/modules/workspace/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 13068198a..be6ce7687 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -2498,6 +2498,9 @@ export class WorkspaceModule implements Module { }; for (const { mount, relativePath } of mountsToSearch) { + // Built before the pass, as glob builds its own, so a pattern globToRegex + // refuses is refused before any mount is searched. + const fileGlobs = input.glob ? mountGlobs(input.glob, mount.config.name) : null; await this.withMount(mount, async () => { // A `path` naming a single file greps just that file; otherwise it is a // directory prefix. (A file path once produced the prefix "notes.md/" @@ -2530,7 +2533,6 @@ export class WorkspaceModule implements Module { } const intents = this.intents.get(mount.config.name)!; - const fileGlobs = input.glob ? mountGlobs(input.glob, mount.config.name) : null; for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) { if (fileGlobs && !fileGlobs.relative.test(report.path) && fileGlobs.prefixed?.test(report.path) !== true) continue; const file = `${mount.config.name}/${report.path}`; From 8d91b341923bc5d1a54ee2f3cc16fef19a092985 Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Wed, 21 Oct 2026 18:18:39 -0400 Subject: [PATCH 4/9] fix(workspace): a `**/` after a brace group of directories matches zero or more A `**/` right after a brace group was always read as not starting a path segment, so after `{src/,lib/}` it still needed a directory, though each alternative ends one and `{src,lib}/**/*.ts` finds the same files with none. Whether it starts a segment there depends on the alternative that matched, so a `**/` at the start of any part of a pattern now reads by what comes before it in the path: zero or more directories where the path so far is empty or ends with a `/`, and otherwise any run of characters and then a directory, as before. So `{src/,draft}**/*.ts` matches `src/main.ts` and `draftx/y.ts`, `draft{s,ing}**/` still needs a directory, and an empty alternative ends a directory only where the group starts one. The parts no longer carry whether they start a segment. Tests: those cases; a `**/` that starts a brace alternative, at a segment's start and not; and a pattern's `+`, `.` and parentheses matching themselves against names their regex reading would also take. The fragment's `**/` sentence says "at the start of a path segment", as the code does. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.fixed.md | 4 +-- src/modules/workspace/index.ts | 21 +++++++------- test/workspace-glob-mount-prefix.test.ts | 28 ++++++++++++++++++- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md index ef0127cd4..110d8979b 100644 --- a/changelog.d/glob-mount-prefixed-patterns.fixed.md +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -12,7 +12,7 @@ now reads mount-prefixed and no longer finds that file; `work/work/z/*.ts`, as the tools show it, does. `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same - way. In both, a `**/` now matches zero or more directories, as in other glob - tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and + way. In both, a `**/` at the start of a path segment now matches zero or + more directories, as in other glob tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and `**/*.md` also finds the `.md` files at a mount's root; it used to need at least one directory there. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 30bdd0a1b..db3e322a8 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -5970,9 +5970,6 @@ function globToRegex(pattern: string): RegExp { let regex = ''; let i = 0; while (i < pattern.length) { - // Whether the part that starts here starts a path segment (see - // globPartToRegex). - const atSegmentStart = i === 0 || pattern[i - 1] === '/'; if (pattern[i] === '{') { // A brace form this can't read is refused. A `{` that never closes, read // as a literal, would almost always answer with no matches, and with it @@ -5998,7 +5995,7 @@ function globToRegex(pattern: string): RegExp { throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","')); } const alternatives = group.split(','); - regex += '(?:' + alternatives.map(a => globPartToRegex(a, atSegmentStart)).join('|') + ')'; + regex += '(?:' + alternatives.map(globPartToRegex).join('|') + ')'; i = closeIdx + 1; continue; } @@ -6009,7 +6006,7 @@ function globToRegex(pattern: string): RegExp { i++; } if (chunk) { - regex += globPartToRegex(chunk, atSegmentStart); + regex += globPartToRegex(chunk); } } return new RegExp(`^${regex}$`); @@ -6050,10 +6047,14 @@ function braceRefusal(pattern: string, problem: string): string { // any other `**` matches any run of characters. const GLOB_TOKENS: Record = { '**/': '(?:.*/)?', '**': '.*', '*': '[^/]*', '?': '[^/]' }; -// One part of a pattern, between brace groups or inside one. A part that -// doesn't start a path segment, such as one right after a brace group, reads -// a `**/` at its start as any run of characters and then a directory. -function globPartToRegex(part: string, atSegmentStart: boolean): string { +// One part of a pattern, between brace groups or inside one. Whether a `**/` +// at its start starts a path segment depends on what comes before it in the +// path, which after a brace group is whichever alternative matched: so it +// reads as GLOB_TOKENS' `**/` where the path so far is empty or ends with a +// `/`, as in `{src/,lib/}**/`, and otherwise as any run of characters and +// then a directory, as in `draft{s,ing}**/`. +const PART_START_GLOBSTAR = '(?:(?<=^|/)(?:.*/)?|(? - m === '**/' && offset === 0 && !atSegmentStart ? '.*/' : GLOB_TOKENS[m] ?? `\\${m}`); + m === '**/' && offset === 0 ? PART_START_GLOBSTAR : GLOB_TOKENS[m] ?? `\\${m}`); } diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts index e396f652b..23e45574f 100644 --- a/test/workspace-glob-mount-prefix.test.ts +++ b/test/workspace-glob-mount-prefix.test.ts @@ -109,12 +109,38 @@ test('a `**/` matches zero or more directories, in a relative or a mount-prefixe assert.deepEqual(await grepFiles({ pattern: 'export', glob: 'project/src/**/*.ts' }), sources); }); -test('a `**/` right after a brace group still needs a directory there', async (t) => { +test("a `**/` right after a brace group needs a directory there where the alternative before it doesn't end one", async (t) => { // Not the start of a path segment, so the `**` is any run of characters. const { glob } = setup(t, { 'notes/drafts.md': 'drafts', 'notes/drafts/one.md': 'one' }); assert.deepEqual(await glob({ pattern: 'notes/draft{s,ing}**/*.md' }), ['notes/drafts/one.md']); }); +test('after a brace group, a `**/` matches zero or more directories after an alternative that ends one', async (t) => { + const { glob } = setup(t, { 'notes/draft.md': 'draft', 'notes/drafts/one.md': 'one', 'notes/ac.md': 'ac', 'notes/ab/c.md': 'abc', 'notes/ax/c.md': 'axc' }); + const sources = ['project/src/lib/util.ts', 'project/src/main.ts']; + assert.deepEqual(await glob({ pattern: 'project/{src/,docs/}**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: 'project/{src,docs}/**/*.ts' }), sources); + // Beside an alternative that doesn't end one, which still needs a directory. + assert.deepEqual(await glob({ pattern: 'notes/{diary/,draft}**/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'notes/drafts/one.md']); + // An empty alternative ends a directory only where the group starts one. + assert.deepEqual(await glob({ pattern: 'project/src/{,lib/}**/*.ts' }), sources); + assert.deepEqual(await glob({ pattern: 'notes/a{,b/}**/c.md' }), ['notes/ab/c.md', 'notes/ax/c.md']); +}); + +test("a `**/` that starts a brace alternative reads as the alternative's place in the path does", async (t) => { + const { glob } = setup(t, { 'notes/ac.md': 'ac', 'notes/ax/c.md': 'axc' }); + // At a segment's start: zero or more directories. + assert.deepEqual(await glob({ pattern: 'project/src/{**/,lib/}*.ts' }), ['project/src/lib/util.ts', 'project/src/main.ts']); + // Not at a segment's start: any run of characters, then a directory. + assert.deepEqual(await glob({ pattern: 'notes/a{**/,b/}c.md' }), ['notes/ax/c.md']); +}); + +test("a pattern's other characters match themselves", async (t) => { + const { glob } = setup(t, { 'project/src/a+b.ts': 'plus', 'project/src/aaab.ts': 'decoy', 'project/src/a.b(c).ts': 'dots', 'project/src/axbc-ts': 'decoy' }); + assert.deepEqual(await glob({ pattern: 'project/src/a+b.ts' }), ['project/src/a+b.ts']); + assert.deepEqual(await glob({ pattern: 'project/src/a.b(c).ts' }), ['project/src/a.b(c).ts']); +}); + test("grep's glob filter takes a mount-prefixed pattern", async (t) => { const { grepFiles } = setup(t); assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); From edc6210aeb594a8c8ae5d89302be48c55ef3ddb8 Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Thu, 22 Oct 2026 10:49:50 -0400 Subject: [PATCH 5/9] fix(workspace): a `/` after a brace group goes into each alternative, so a `**` ending one reads as `**/` A `**` that ends a brace alternative, with a `/` right after the group, was read as any run of characters and then a literal `/`, never as a `**/`. So `{src/**,lib}/*.ts` didn't match `src/main.ts`, though its expansion `src/**/*.ts` does, since a `**/` at the start of a path segment matches zero or more directories here. `{a,b}/x` and `{a/,b/}x` name the same paths, so the `/` now goes into each alternative before it is read. Each alternative's own `**` meets it and reads as its expansion does. Without a `/` after the group, an alternative's `**` is any run of characters, as before. A group inside another is still refused before any of this. Tests: - test/workspace-glob-mount-prefix.test.ts: `project/{src/**,docs}/*.ts` and `project/{src/**,docs/**}/*.ts` find `project/src/main.ts` beside the rest, and `project/{src/**,docs}.ts` still reads `**` as any run of characters. - test/workspace-glob-unclosed-brace.test.ts: `{a,{b/**,c}}/x` is refused as a group inside another. Found by an internal review run (af401-merge-r3). Co-Authored-By: Claude Opus 5.5 --- src/modules/workspace/index.ts | 9 +++++++-- test/workspace-glob-mount-prefix.test.ts | 10 ++++++++++ test/workspace-glob-unclosed-brace.test.ts | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index db3e322a8..db515a24c 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -5994,9 +5994,14 @@ function globToRegex(pattern: string): RegExp { if (!group.includes(',')) { throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","')); } - const alternatives = group.split(','); + // A `/` right after the group goes into each alternative, as `{a,b}/x` + // and `{a/,b/}x` name the same paths, so an alternative's own `**` + // meets it and reads as a `**/` does, as in the group's expansion: + // `{src/**,lib}/*.ts` is `src/**/*.ts` or `lib/*.ts`. + const slash = pattern[closeIdx + 1] === '/'; + const alternatives = group.split(',').map((alternative) => (slash ? alternative + '/' : alternative)); regex += '(?:' + alternatives.map(globPartToRegex).join('|') + ')'; - i = closeIdx + 1; + i = closeIdx + (slash ? 2 : 1); continue; } // Accumulate non-brace characters, convert as a chunk diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts index 23e45574f..abfc0ac25 100644 --- a/test/workspace-glob-mount-prefix.test.ts +++ b/test/workspace-glob-mount-prefix.test.ts @@ -135,6 +135,16 @@ test("a `**/` that starts a brace alternative reads as the alternative's place i assert.deepEqual(await glob({ pattern: 'notes/a{**/,b/}c.md' }), ['notes/ax/c.md']); }); +test('a `**` that ends a brace alternative, with a `/` after the group, matches zero or more directories there, as the expansion does', async (t) => { + // `{src/**,docs}/*.ts` names what `src/**/*.ts` and `docs/*.ts` name. + const { glob } = setup(t, { 'project/docs/guide.ts': 'guide' }); + const all = ['project/docs/guide.ts', 'project/src/lib/util.ts', 'project/src/main.ts']; + assert.deepEqual(await glob({ pattern: 'project/{src/**,docs}/*.ts' }), all); + assert.deepEqual(await glob({ pattern: 'project/{src/**,docs/**}/*.ts' }), all); + // Without a `/` after the group, the `**` is any run of characters, as before. + assert.deepEqual(await glob({ pattern: 'project/{src/**,docs}.ts' }), ['project/src/lib/util.ts', 'project/src/main.ts']); +}); + test("a pattern's other characters match themselves", async (t) => { const { glob } = setup(t, { 'project/src/a+b.ts': 'plus', 'project/src/aaab.ts': 'decoy', 'project/src/a.b(c).ts': 'dots', 'project/src/axbc-ts': 'decoy' }); assert.deepEqual(await glob({ pattern: 'project/src/a+b.ts' }), ['project/src/a+b.ts']); diff --git a/test/workspace-glob-unclosed-brace.test.ts b/test/workspace-glob-unclosed-brace.test.ts index 84d2ae797..296f01436 100644 --- a/test/workspace-glob-unclosed-brace.test.ts +++ b/test/workspace-glob-unclosed-brace.test.ts @@ -95,6 +95,8 @@ test('a "{" that never closes is named as that, with a closed group after it', ( test('glob and grep refuse a group inside another', () => { assertRefused('glob', { pattern: 'src/{a,{b,c}}.ts' }, 'src/{a,{b,c}}.ts', NESTED); assertRefused('grep', { pattern: 'export', glob: 'src/{a,{b,c}}.ts' }, 'src/{a,{b,c}}.ts', NESTED); + // …and so is one with a `/` after it, which an alternative would otherwise take in + assertRefused('glob', { pattern: '{a,{b/**,c}}/x' }, '{a,{b/**,c}}/x', NESTED); }); test('glob and grep refuse a group with no ",", an empty one included', () => { From d79b25b359926ab5453fc378eb66d8a396417266 Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Thu, 22 Oct 2026 12:45:34 -0400 Subject: [PATCH 6/9] test(workspace): pin a mount-named pattern's readings with a path and across mounts; text for the tool-text change From an internal review run (af401-cut) of the merge of main: - a pattern with no `/` that starts with a mount's name is read relative to the path, not as naming the mount, and a first segment that is a brace group or a wildcard names no mount; - with a path, a mount-prefixed pattern whose rest names a directory outside the path finds nothing there; - grep's filter, with a path, is confined to it: notes/*.md finds notes/README.md without the path, and nothing within notes/diary; - grep reads a filter naming a mount as written in every other mount; - the escaping test's decoys now include axb(c).ts, so an unescaped `.` fails it. walkCap's doc names the directory a mount-prefixed pattern's walk starts from. The fragment says that glob's and grep's tool texts changed, so the first request after the upgrade that carries the workspace's tools misses the prompt cache from its tool block on. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.fixed.md | 5 +++- src/modules/workspace/types.ts | 6 +++-- test/workspace-glob-mount-prefix.test.ts | 23 +++++++++++++++---- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md index 110d8979b..d97f78adc 100644 --- a/changelog.d/glob-mount-prefixed-patterns.fixed.md +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -15,4 +15,7 @@ way. In both, a `**/` at the start of a path segment now matches zero or more directories, as in other glob tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and `**/*.md` also finds the `.md` files at a mount's root; it used to need at - least one directory there. + least one directory there. `glob`'s description and its `pattern` + parameter's description changed, and so did `grep`'s `glob` parameter's + description, so the first request after the upgrade that carries the + workspace's tools misses the prompt cache from its tool block on. diff --git a/src/modules/workspace/types.ts b/src/modules/workspace/types.ts index cfd35adad..707fbec7a 100644 --- a/src/modules/workspace/types.ts +++ b/src/modules/workspace/types.ts @@ -259,8 +259,10 @@ export interface MountConfig { * `glob`, `grep`) take up to it from their scope's start every time: * `glob`'s is the directory its pattern's segments before the first * wildcard name beneath its `path`, and `grep`'s the deeper of its `path` - * and its `glob` filter's directory, as far as disk lists each segment - * with that spelling as a directory, not a link. Each directory's listing + * and its `glob` filter's directory. For a pattern or filter whose first + * segment names the mount, that directory is the one its rest names from + * the mount's root. Either way, the walk descends as far as disk lists + * each segment with that spelling as a directory, not a link. Each directory's listing * is read whole, so a deletion is seen wherever the walk lists, whatever * it took. * diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts index abfc0ac25..941ceccb4 100644 --- a/test/workspace-glob-mount-prefix.test.ts +++ b/test/workspace-glob-mount-prefix.test.ts @@ -89,14 +89,27 @@ test('with a path, the pattern may be relative to it or mount-prefixed', async ( assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'project/**' }), []); }); +test('a first segment that is a brace group or a wildcard names no mount', async (t) => { + // Read as written in every mount, so neither finds notes/diary's files, + // which notes/diary/*.md finds. + const { glob } = setup(t); + assert.deepEqual(await glob({ pattern: '{notes,project}/diary/*.md' }), []); + assert.deepEqual(await glob({ pattern: 'note?/diary/*.md' }), []); +}); + test("with a path, a pattern naming the path's mount reads mount-prefixed, within the path", async (t) => { - const { glob, grepFiles } = setup(t); + const { glob, grepFiles } = setup(t, { 'notes/drafts/one.md': 'one', 'notes/diary/notes1': 'a note' }); const diary = ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']; assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/**/*.md' }), diary); - // notes/README.md lies outside the path. + // notes/README.md lies outside the path, and so does notes/drafts. assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/*.md' }), []); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/drafts/*.md' }), []); assert.deepEqual(await grepFiles({ pattern: 'entry', path: 'notes/diary', glob: 'notes/diary/*-13.md' }), ['notes/diary/2026-10-13.md']); - assert.deepEqual(await grepFiles({ pattern: 'entry', path: 'notes/diary', glob: 'notes/*.md' }), []); + assert.deepEqual(await grepFiles({ pattern: 'readme', glob: 'notes/*.md' }), ['notes/README.md']); + assert.deepEqual(await grepFiles({ pattern: 'readme', path: 'notes/diary', glob: 'notes/*.md' }), []); + // A pattern with no `/` names no mount, even one that starts with a + // mount's name, so it's read relative to the path. + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes1' }), ['notes/diary/notes1']); }); test('a `**/` matches zero or more directories, in a relative or a mount-prefixed pattern', async (t) => { @@ -146,7 +159,7 @@ test('a `**` that ends a brace alternative, with a `/` after the group, matches }); test("a pattern's other characters match themselves", async (t) => { - const { glob } = setup(t, { 'project/src/a+b.ts': 'plus', 'project/src/aaab.ts': 'decoy', 'project/src/a.b(c).ts': 'dots', 'project/src/axbc-ts': 'decoy' }); + const { glob } = setup(t, { 'project/src/a+b.ts': 'plus', 'project/src/aaab.ts': 'decoy', 'project/src/a.b(c).ts': 'dots', 'project/src/axbc-ts': 'decoy', 'project/src/axb(c).ts': 'decoy' }); assert.deepEqual(await glob({ pattern: 'project/src/a+b.ts' }), ['project/src/a+b.ts']); assert.deepEqual(await glob({ pattern: 'project/src/a.b(c).ts' }), ['project/src/a.b(c).ts']); }); @@ -155,6 +168,8 @@ test("grep's glob filter takes a mount-prefixed pattern", async (t) => { const { grepFiles } = setup(t); assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'notes/diary/*.md' }), ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']); assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'project/src/*.ts' }), ['project/src/main.ts']); + // In every other mount, a filter naming a mount is read as written. + assert.deepEqual(await grepFiles({ pattern: 'entry', glob: 'notes/*.md' }), ['project/notes/todo.md']); // Relative filters still match relative to each mount. assert.deepEqual(await grepFiles({ pattern: 'entry', glob: '**/*.md' }), [ 'notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'project/docs/guide.md', 'project/notes/todo.md', From 8eccdf94db49900d499a92524837e7bf64a90337 Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Thu, 22 Oct 2026 13:11:28 -0400 Subject: [PATCH 7/9] docs(workspace): the self-named narrowing gets its own breaking entry; glob's and grep's texts say how other mounts read a pattern In a mount holding a directory named like itself, a pattern written relative to the mount (work/z/*.ts for work/work/z/a.ts) stops finding a file it finds today, so that narrowing moves out of the fixed fragment into glob-mount-prefixed-patterns.breaking.md, with whom it reaches, the path to write instead and what's unchanged. The fixed fragment keeps the mount-prefixed reading, the zero-directory `**/` and the brace-group reading, and points to Breaking for the narrowing. glob's description and grep's glob parameter now say that in every other mount a pattern is read as written, so a mount-prefixed pattern also finding a matching path inside another mount follows from the text. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.breaking.md | 13 +++++++++++++ changelog.d/glob-mount-prefixed-patterns.fixed.md | 11 ++++++----- src/modules/workspace/index.ts | 4 ++-- 3 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 changelog.d/glob-mount-prefixed-patterns.breaking.md diff --git a/changelog.d/glob-mount-prefixed-patterns.breaking.md b/changelog.d/glob-mount-prefixed-patterns.breaking.md new file mode 100644 index 000000000..b110156d0 --- /dev/null +++ b/changelog.d/glob-mount-prefixed-patterns.breaking.md @@ -0,0 +1,13 @@ +- **Agents, and whoever writes their prompts, on a mount that holds a + directory named like the mount itself:** a `workspace--glob` pattern, or a + `workspace--grep` `glob` filter, whose first segment is that mount's name + is now read mount-prefixed in that mount, from its root, and no longer as + written there. So a pattern written relative to the mount, such as + `work/z/*.ts` for the file the tools show as `work/work/z/a.ts`, no longer + finds that file. Where nothing else matches, the agent meets it as "no + matches". + - **Migration:** write the path as the tools show it: `work/work/z/*.ts` + finds `work/work/z/a.ts`. + - **Unchanged:** in every other mount, and for a pattern whose first + segment names no mount, the pattern is still matched relative to the + mount (or to `path`). diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md index d97f78adc..b6ec9587a 100644 --- a/changelog.d/glob-mount-prefixed-patterns.fixed.md +++ b/changelog.d/glob-mount-prefixed-patterns.fixed.md @@ -7,15 +7,16 @@ the mount's root, in place of the pattern as written. Every other mount still matches the pattern relative to the mount (or to `path`), so `notes/*.md` still finds `project/notes/todo.md` beside the `notes` mount's - own files. In a mount holding a directory named like itself, a pattern - written relative to the mount, such as `work/z/*.ts` for `work/work/z/a.ts`, - now reads mount-prefixed and no longer finds that file; `work/work/z/*.ts`, - as the tools show it, does. + own files. In a mount holding a directory named like itself, this narrows + what a pattern written relative to the mount finds (see Breaking). `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same way. In both, a `**/` at the start of a path segment now matches zero or more directories, as in other glob tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and `**/*.md` also finds the `.md` files at a mount's root; it used to need at - least one directory there. `glob`'s description and its `pattern` + least one directory there. After a brace group whose alternatives each end a + directory, a `**/` matches zero or more directories, and so does a `**` + that ends an alternative before a `/`, so `{src/,lib/}**/*.ts` and + `{src/**,lib}/*.ts` find `src/main.ts`, as `src/**/*.ts` does. `glob`'s description and its `pattern` parameter's description changed, and so did `grep`'s `glob` parameter's description, so the first request after the upgrade that carries the workspace's tools misses the prompt cache from its tool block on. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index db515a24c..690f386a3 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1852,7 +1852,7 @@ export class WorkspaceModule implements Module { { name: 'glob', description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes. ' - + 'The pattern is relative to path (or to each mount\'s root). In the mount its first segment names, it is read mount-prefixed instead, like the paths it returns, within path if one is given.' + globDirect, + + 'The pattern is relative to path (or to each mount\'s root). In the mount its first segment names, it is read mount-prefixed instead, like the paths it returns, within path if one is given; in every other mount, as written.' + globDirect, inputSchema: { type: 'object' as const, properties: { @@ -1874,7 +1874,7 @@ export class WorkspaceModule implements Module { properties: { pattern: { type: 'string', description: 'Regular expression pattern' }, path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' }, - glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts"); in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts")' }, + glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts"); in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), and in every other mount, as written' }, contextBefore: { type: 'integer', description: 'Context lines before match' }, contextAfter: { type: 'integer', description: 'Context lines after match' }, }, From e68118e7131eb937a0bc4ee2edffc10f4cb12e1e Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Thu, 22 Oct 2026 13:25:00 -0400 Subject: [PATCH 8/9] docs(workspace): the breaking entry says what an agent gets, not a quoted tool string "the agent meets it as "no matches"" read as the tools' own words, which neither tool says: glob answers {"matches": [], "count": 0} and grep {"results": [], "totalMatches": 0}, each a success, not an error. Co-Authored-By: Claude Opus 5.5 --- changelog.d/glob-mount-prefixed-patterns.breaking.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/changelog.d/glob-mount-prefixed-patterns.breaking.md b/changelog.d/glob-mount-prefixed-patterns.breaking.md index b110156d0..7cf604524 100644 --- a/changelog.d/glob-mount-prefixed-patterns.breaking.md +++ b/changelog.d/glob-mount-prefixed-patterns.breaking.md @@ -4,8 +4,9 @@ is now read mount-prefixed in that mount, from its root, and no longer as written there. So a pattern written relative to the mount, such as `work/z/*.ts` for the file the tools show as `work/work/z/a.ts`, no longer - finds that file. Where nothing else matches, the agent meets it as "no - matches". + finds that file. Where nothing else matches, the agent gets an answer + with no matches (`glob`'s `"matches": []`, `grep`'s `"results": []`), not + an error. - **Migration:** write the path as the tools show it: `work/work/z/*.ts` finds `work/work/z/a.ts`. - **Unchanged:** in every other mount, and for a pattern whose first From 181c5b6cd5e0eeac9cd0c7edc7805e0c4d0e4e7f Mon Sep 17 00:00:00 2001 From: Gordon-2414 Date: Thu, 22 Oct 2026 13:33:44 -0400 Subject: [PATCH 9/9] docs(workspace): the breaking entry covers a directory named like the mount beneath a path; text and a test from #696's review From the automated review of #696: - the breaking entry said the narrowing needs a directory named like the mount at its root. With a `path`, a pattern naming the path's mount is read mount-prefixed within it, so glob {path: "notes/diary", pattern: "notes/*.md"}, written relative to the path for notes/diary/notes/x.md, no longer finds it. The entry says so, with the path to write instead, and the path test now holds that file: notes/*.md finds nothing there, and notes/diary/notes/*.md finds it, with the path and without; - walkCap's doc: a mount-prefixed pattern's walk starts at the deeper of `path` and the rest's directory, and walks nothing where neither holds the other; - grep's comment above filterDir points to the mount-prefixed reading that takes the filter's place in the mount it names; - a comment in the mount-prefix tests still described the reading kept beside the prefixed one in the mount a pattern names; it now says the as-written reading is every other mount's. Co-Authored-By: Claude Opus 5.5 --- .../glob-mount-prefixed-patterns.breaking.md | 20 +++++++++++-------- src/modules/workspace/index.ts | 3 ++- src/modules/workspace/types.ts | 8 +++++--- test/workspace-glob-mount-prefix.test.ts | 14 ++++++++----- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/changelog.d/glob-mount-prefixed-patterns.breaking.md b/changelog.d/glob-mount-prefixed-patterns.breaking.md index 7cf604524..52706ea4c 100644 --- a/changelog.d/glob-mount-prefixed-patterns.breaking.md +++ b/changelog.d/glob-mount-prefixed-patterns.breaking.md @@ -1,14 +1,18 @@ -- **Agents, and whoever writes their prompts, on a mount that holds a +- **Agents, and whoever writes their prompts, where a mount holds a directory named like the mount itself:** a `workspace--glob` pattern, or a `workspace--grep` `glob` filter, whose first segment is that mount's name - is now read mount-prefixed in that mount, from its root, and no longer as - written there. So a pattern written relative to the mount, such as - `work/z/*.ts` for the file the tools show as `work/work/z/a.ts`, no longer - finds that file. Where nothing else matches, the agent gets an answer - with no matches (`glob`'s `"matches": []`, `grep`'s `"results": []`), not - an error. + is now read mount-prefixed in that mount, from its root and within + `path`, and no longer as written there. So a pattern written relative to + the mount, such as `work/z/*.ts` for the file the tools show as + `work/work/z/a.ts`, no longer finds that file. With a `path`, the same + goes for such a directory anywhere beneath it: `glob` with `path: + "notes/diary"` and `pattern: "notes/*.md"`, written relative to the path + for `notes/diary/notes/x.md`, no longer finds it. Where nothing else + matches, the agent gets an answer with no matches (`glob`'s + `"matches": []`, `grep`'s `"results": []`), not an error. - **Migration:** write the path as the tools show it: `work/work/z/*.ts` - finds `work/work/z/a.ts`. + finds `work/work/z/a.ts`, and `notes/diary/notes/*.md` finds + `notes/diary/notes/x.md`, with that `path` or without one. - **Unchanged:** in every other mount, and for a pattern whose first segment names no mount, the pattern is still matched relative to the mount (or to `path`). diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 690f386a3..bf684a69c 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -4664,7 +4664,8 @@ export class WorkspaceModule implements Module { const fileGlob = input.glob ? globToRegex(input.glob) : null; // The filter matches mount-relative paths, so every file it lets through // lies beneath its literal directory, and the walk starts at the deeper of - // that and `path`. + // that and `path`. In the mount a filter's first segment names, its + // mount-prefixed reading takes the filter's place (below). const filterDir = input.glob ? literalDirOf(input.glob) : ''; const contextBefore = input.contextBefore ?? 0; const contextAfter = input.contextAfter ?? 0; diff --git a/src/modules/workspace/types.ts b/src/modules/workspace/types.ts index 707fbec7a..b1c97fe53 100644 --- a/src/modules/workspace/types.ts +++ b/src/modules/workspace/types.ts @@ -260,9 +260,11 @@ export interface MountConfig { * `glob`'s is the directory its pattern's segments before the first * wildcard name beneath its `path`, and `grep`'s the deeper of its `path` * and its `glob` filter's directory. For a pattern or filter whose first - * segment names the mount, that directory is the one its rest names from - * the mount's root. Either way, the walk descends as far as disk lists - * each segment with that spelling as a directory, not a link. Each directory's listing + * segment names the mount, either tool starts at the deeper of its `path` + * and the directory the rest names from the mount's root, and walks + * nothing where neither holds the other. Either way, the walk descends as + * far as disk lists each segment with that spelling as a directory, not a + * link. Each directory's listing * is read whole, so a deletion is seen wherever the walk lists, whatever * it took. * diff --git a/test/workspace-glob-mount-prefix.test.ts b/test/workspace-glob-mount-prefix.test.ts index 941ceccb4..e5aa4220a 100644 --- a/test/workspace-glob-mount-prefix.test.ts +++ b/test/workspace-glob-mount-prefix.test.ts @@ -77,8 +77,8 @@ test('relative patterns match relative to every mount', async (t) => { 'project/README.md', 'project/docs/guide.md', 'project/notes/todo.md', ]); assert.deepEqual(await glob({ pattern: '*.md' }), ['notes/README.md', 'project/README.md']); - // A pattern whose first segment is a mount's name keeps its relative - // reading too: project/notes/ still matches, beside the notes mount's root. + // In every other mount, a pattern whose first segment is a mount's name is + // read as written: project/notes/ matches, beside the notes mount's root. assert.deepEqual(await glob({ pattern: 'notes/*.md' }), ['notes/README.md', 'project/notes/todo.md']); }); @@ -98,11 +98,15 @@ test('a first segment that is a brace group or a wildcard names no mount', async }); test("with a path, a pattern naming the path's mount reads mount-prefixed, within the path", async (t) => { - const { glob, grepFiles } = setup(t, { 'notes/drafts/one.md': 'one', 'notes/diary/notes1': 'a note' }); - const diary = ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md']; + const { glob, grepFiles } = setup(t, { 'notes/drafts/one.md': 'one', 'notes/diary/notes1': 'a note', 'notes/diary/notes/x.md': 'x' }); + const diary = ['notes/diary/2026-10-12.md', 'notes/diary/2026-10-13.md', 'notes/diary/notes/x.md']; assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/**/*.md' }), diary); - // notes/README.md lies outside the path, and so does notes/drafts. + // notes/README.md lies outside the path, and so does notes/drafts. And a + // pattern relative to the path that starts with the mount's name names the + // mount: notes/diary/notes/x.md is found by its path as the tools show it. assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/*.md' }), []); + assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/diary/notes/*.md' }), ['notes/diary/notes/x.md']); + assert.deepEqual(await glob({ pattern: 'notes/diary/notes/*.md' }), ['notes/diary/notes/x.md']); assert.deepEqual(await glob({ path: 'notes/diary', pattern: 'notes/drafts/*.md' }), []); assert.deepEqual(await grepFiles({ pattern: 'entry', path: 'notes/diary', glob: 'notes/diary/*-13.md' }), ['notes/diary/2026-10-13.md']); assert.deepEqual(await grepFiles({ pattern: 'readme', glob: 'notes/*.md' }), ['notes/README.md']);