Skip to content

feat(workspace): a mount's include names the files it holds, and is its boundary - #536

Merged
nissa-seru merged 40 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-mount-include
Oct 19, 2026
Merged

nissa-seru merged 40 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-mount-include

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

A workspace mount can now name the files it holds with include, in the pattern forms ignore takes, read for files. A file it doesn't cover is outside the mount. No tool reads or writes it, and nothing takes it into the workspace's history, so a host can version only the files it owns. connectome-host needs this for its _config mount, which versions the host's config files and should hold only those (anima-research/connectome-host#278). On every mount, it also makes each tool read a path in one spelling, which fixes the two issues below.

Fixes #351.
Fixes #471.

Problem

A mount versions everything under its path that ignore doesn't cover, and ignore holds only for the passes that name no path: a sync without one, and the watcher's. On main, with ignore: ["*.bak"], a sync naming work/gate.json.bak takes the file in, the agent's read reads it and its write of work/new.bak versions that, and after a branch switch materializeMount writes back an ignored file the workspace's history held from before the ignore (each run on main, below). An ignore list also can't name every file a host should leave alone. Its patterns are names, *.ext and dir/**, so it names no family of files by a common prefix, such as the owner-only gate.json.unreadable-<suffix> copies #371 adds, and it can't name a file nobody expected. Editors' swap and backup files are already passed over on every mount (#460).

Changes

  • MountConfig.include takes patterns in ignore's forms, read for files. A name or an *.ext glob covers a file by its own name, at any depth, and a dir/** glob covers what lies beneath that directory, and not a file named like it. Unlike ignore, a pattern that matches a directory's name covers nothing beneath it, since each file is covered by its own name: include: ["gate.json"] holds every file named gate.json, and no other file beneath a directory of that name. Left out, nothing changes. The module refuses a bad include when it's created, naming the mount and the pattern: an empty list, or an entry the matcher can't read, meaning one that isn't a string, a path with / that isn't a dir/** glob, a * anywhere but a leading *., an empty, . or .. segment, or a \ where it's the platform's separator, as on Windows. Any other pattern is a file name, matched literally, as ignore reads its patterns, so [id].tsx names the file of that name.
  • What it guarantees is content. No workspace path reads, stores, serves or writes back a file the include leaves out, including one in a nested mount's directory that mount's include leaves out, and no tool lists, matches or names one. A refusal may show that something occupies a path the caller named. On a mount that follows links, it may also say whether that path leads out of the mount or into a nested mount that covers it, with the path to use there. A link the include covers is described as on any mount, its target included unless that's a file the include leaves out. Otherwise, a refusal names nothing the include leaves out beyond the path the caller gave, which it may spell as disk does, and on a mount that doesn't follow links, it tells nothing of where a link the include leaves out leads. Whether a name is taken isn't withheld, and a sync's passedOver counts what it passed over. Outside the mount:
    • a walk passes over it, counting it under passedOver as "outside the mount's include";
    • no pass decides it, whether the walk, the watcher's changes or the tracked lists name it, so history from before the include stays as it was. A watcher's batch of changes with nothing else in it takes no turn of the mount;
    • materializeMount and materialize never write it back, even when the workspace's history holds it. A materialize naming a directory inside the mount counts each such entry beneath it under passedOver, with that reason and without its path, a deletion or conflict held there included, while one of the whole mount leaves those alone and uncounted, as status doesn't count one;
    • read, read_image, write, edit, edit_many, delete, readBinary, writeBinary, readBinaryFromDisk and readFileFromDisk refuse any path the include doesn't cover, a directory's included, since a path can be a directory on one side and a file on the other. They say it's outside the mount's include, and that calling again changes nothing, since the host's operator sets the include in the mount's configuration (readFileFromDisk's new error code is outside_include);
    • resolveAbsolutePath returns null for such a path, as for one that leaves the mount;
    • a sync, materialize, grep, glob or ls that names a path the include doesn't cover takes it as a directory, its scope, exactly where a listing names one: where disk has a directory reached from the mount's root through directories, with no link among them, or the workspace's history holds an entry beneath it that the include covers, and that a nested mount's include covers too where it lies in that mount's directory. A directory ignore covers is a scope too when it's named, though a listing passes over it, as a sync that names it takes in what's there. Each refuses any other path the include doesn't cover. A covered path is a file to a sync, materialize or grep when disk or history has a file there, and otherwise a directory; a glob takes its path as a directory, as on main. Beneath a directory, each passes over what the include doesn't cover;
    • the mount's status counts only what's inside, and every listing shows only covered files. An ls that isn't recursive names each directory directly beneath its path that disk has, the ignore list's aside, and each that a covered entry in the workspace's history implies, but none that only history outside the include implies, and nothing beyond a link, since the walk descends none. ls without a path lists each mount's include.
  • One spelling of a path, for every mount. The tools read a path after its mount's name one way: / between segments, \ too on Windows, no empty or . segment and no trailing /, and a .. takes back the segment before it. One with nothing left to take back climbs above the mount's root and is refused, whatever it reaches afterwards, a path that comes back in by the mount directory's own name included, which main's traversal guard let through. There readFileFromDisk and resolveAbsolutePath reached the file, and the other tools failed with the store's "Invalid tree path". Its refusal says climbs above the root of mount "work"., where main said resolves outside mount "work" for a climb that left the mount. A separator straight after the mount's name is refused too, since it would make the rest an absolute path. So on Linux and macOS, the include, the workspace's history, the walk, the traversal guard and the disk all judge one string. Windows trims a last segment's trailing spaces and periods and reads a : in a segment as a drive or a stream, which this change leaves as main has it (Not verified, below). On a mount without an include, this changes the entry history keeps for work/a//b.txt to a/b.txt, the file the disk operation already reached, where main kept the spelling as written (Workspace: a path with an empty segment is a second tree entry for the same disk file #471), and work/./a.txt or work/sub/../a.txt, whose writes failed with the store's "Invalid tree path", now name a.txt, as a glob or grep naming work/src/../src or work/./src, which failed the same way, now searches src. A sync naming work/d/ records what it takes in as d/a.txt, where main recorded d//a.txt (workspace: sync of a directory path with a trailing slash records its files as d//a.txt #351), and a materialize naming work/docs/ takes docs, where main found nothing. edit and edit_many read a path this way too, so feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463's refusal of a path not spelled plainly goes, since its reason was that the workspace kept an entry for each spelling. Each spelling now edits the one entry, and two spellings in one edit_many call are one file, planned once. A path that names the mount's root is still refused.
  • A key no spelling names is outside the mount. Because main kept a path as written, a store can hold a//b.txt or c/. Such a key is outside the mount. No tool lists, counts, syncs or writes it, and since no spelling names it, a materialize doesn't count it. History keeps it as it was. Where such a write reached disk, the file is the one its new spelling names, and the next sync of the mount takes it in. A draft written under such a key that hadn't reached disk when the host upgrades stays in history and never reaches disk, and workspace-path-spellings.breaking.md says how to recover it.
  • Directories are walked unless ignore covers them, and ignore still applies to what include covers.
  • The framework keeps its own files (tool-results/, refs/, background-scripts/) in the first read-write mount without an include, since a mount with one would refuse them. A setup whose only writable mount has an include is treated as having none, which each of those three already handles, and the over-cap notice, the background-script log text and the log line for a fetched reference it can't store say there is no writable mount without an include. getMounts() reports a mount's include for this. What the framework kept in a mount before the mount had an include is outside it once it has one, unless the include covers it, so a read of a path an earlier spill note or reference gave is refused.
  • Links. On a mount that follows symlinks, a path is judged where it lands as well as where it's named, as disk spells it there: a link at a covered path or among its parent directories is followed while what it reaches is a directory or a file the include covers. A covered path whose landing realpath can't resolve, as for a loop, more than 40 links or a directory this process can't search, is refused by read, write, delete and readFileFromDisk, and by our reading by every other file tool, which parse a path the same way, as Unable to resolve file: <path> with code not_found, the words a disk read gives such a path, since where it lands can't be judged: history taken in through it before the include isn't served, and nothing is written through it. A covered path that reaches a file the include leaves out is refused as one that leaves the mount, in the words a place a nested mount's include leaves out gets: the file tools refuse it before anything is stored, and they and readFileFromDisk answer Symlink escape detected: <path>, code escape. A listing from where a link leads passes over such a file, and nothing a materialize writes through that path reaches disk, a file it would create included. So with include: ["docs/**"], where docs is a link to site/docs, every file beneath docs/ lands beneath site/docs/, which docs/** doesn't cover, and the mount holds none of them unless the include names site/docs/** too. On one that doesn't, a link on a path's way is refused, as on any such mount. Its refusal names an inside target only where the include covers the link and a tool could name the target: a directory, or a file the include covers. A link the include doesn't cover is outside the mount itself, so its refusal names only the link, with nothing of where it leads, and readFileFromDisk gives it the code of a link the mount doesn't follow where it stands, whatever it leads to: parent_symlink on the path's way. A link the include covers, at the path itself, gets symlink, however the path is spelled, except one into a nested mount's directory whose refusal names that mount, which gets other_mount. On a mount that follows links, a link the include doesn't cover that leads to a directory is passed over with the reason a mount without an include gives a link, "a symlink, not walked", since a name or an extension glob can cover what lies beneath it and what's read through it is judged by its path and where it lands, and history beneath it is unverified for that reason, while a link to a file, or one that leads nowhere, is outside the include by its name. On one that doesn't, every link the include doesn't cover gets that reason, since nothing reads through one there, and such a mount resolves none to tell where it leads.
  • History in a nested mount's directory that the outer mount took in before that mount was nested there, and that mount's include leaves out, is inert history: no pass decides it, and no listing, sync or grep names it.
  • A path in another mount's directory nested in this one is that mount's, whatever this mount's include says. Every tool names the mount that holds it and the path to use there, as main does on a mount without an include, so a file there that the include doesn't cover gets that answer rather than the include's refusal. Past that directory, an answer shows the rest of the path as given. Through a link this mount follows, main refuses a path that reaches such a directory, naming that mount and the place the path reaches there. Here it names them only where that place is a directory, or a file that mount's include covers with no file on its way; otherwise no answer names that mount or the place, and the file tools and readFileFromDisk refuse the path as one that leaves the mount (Symlink escape detected: <path>, code escape). On a mount that doesn't follow the link, readFileFromDisk's code is other_mount only where the refusal names that mount. A walk passes over that directory by its real path, and by which directory it is, as well as by its spelling, so a walk through a link to a directory above it lists none of that mount's files. main's walk listed them there as unverified entries, with or without an include. A push asks where a file lies before it reads it for a hash, as well as after, so it reads no file in such a directory.
  • On a disk that folds names, as macOS's does by default, a path is judged as disk spells it, as the walk judges what it lists. So a covered name whose file disk spells otherwise, such as gate.json where disk has GATE.JSON, is outside the mount, and its refusal shows the path as given, which names nothing the caller didn't. A path that names one of the workspace's entries exactly, or does once its directories take disk's spelling, keeps the entry's spelling, as main's parse gives it, and what lies past a link on a path's way keeps the caller's spelling, since the parse reads nothing past a link. Otherwise answers name a path in disk's spelling, a name the include leaves out included, since how disk spells a name the caller gave is no content of the file. Only the include's own refusal shows the path exactly as given, even where the parse respells it, since it answers the path asked about. A link's target is judged and named as disk spells it. resolveAbsolutePath is synchronous and judges the include on the path as given, following no link, so on such a disk it reaches a covered name's file that disk spells otherwise, which the tools refuse and the walk passes over, and it's null for a covered file named in another case, which the tools read. It compares a path with a nested mount's directory loosely, in case and Unicode normalization, as a disk that folds names may, so it gives null for any spelling that may reach that directory. On a disk that tells names apart, that includes a directory of the outer mount's own spelled like a nested one in another case.
  • Four cases fall outside the guarantee, as for any nested mount, since a nested mount's root is placed where disk has it at each start, and its directory is matched by spelling. On a disk that folds names, a mount configured through a link that spells a directory otherwise than disk does isn't found nested, and the outer mount holds its files as its own. On such a disk, a path through a link of the outer mount that spells a nested mount's directory otherwise than that mount's configured path does, whether the link or the configured path is the one spelled otherwise than disk, isn't found to lead there, and the outer mount reads, lists, searches and writes there as its own, creating files too, though a walk from its root passes over the directory. On such a disk, a place in a nested mount's directory that a link does lead to is judged by that mount's include, and named, in the spelling the link's target gives, so a refusal can name a file disk spells outside that include, or leave a covered one unnamed. And a nested mount whose root moves while running, as acceptRoot takes, stays placed where it was until the next start. Each is a way round the nested mount's include. Finding nesting by identity would close the first three, and acceptRoot reading the nesting again the fourth, all in the nested-mount code main has.
  • docs/tool-presentation.md now says that on a disk that folds names, the tool catalogue answers only under its exact spelling, and a write under another spelling writes the ordinary file at that name on disk, which any agent can read under that spelling. This change leaves that as main has it.
  • Changelog fragments: mount-include.added.md, workspace-path-spellings.fixed.md and workspace-path-spellings.breaking.md, whose operator-facing part names who must act. workspace-link-into-nested-code.fixed.md corrects a line of 0.23.0's notes: a file that is itself a link into a nested mount's directory gets other_mount from readFileFromDisk, as 0.23.0 already does, not symlink. The edit planner's two pending fragments drop the refusal of a path not spelled plainly.

Without include, a mount changes in reading each path in one spelling, and in a walk through a link to a directory above a nested mount's, which no longer lists that mount's files. Two tests in connectome-host's test/identity-and-surfaces.test.ts pinned main's answers there, and anima-research/connectome-host#331, merged, changed them to hold with this change and with 0.22.2. anima-research/connectome-host#335, merged, keeps connectome-host's mount-key checks typechecking against this change's MountConfig by leaving include out of them, so a recipe's include is still refused as an unknown key. A host that sets an include also meets readFileFromDisk's new code, outside_include, and a null from resolveAbsolutePath for a path the include doesn't cover, as for one that leaves the mount. connectome-host's step that takes this release and gives _config an include is anima-research/connectome-host#337.

What each part is for

It exists so a mount can name the files it holds, as connectome-host's _config should hold only gate.json and gate.js (anima-research/connectome-host#278), with the include as the boundary of the mount's content everywhere.

  • The config field and its validation. The field takes one or more patterns in ignore's forms, and the module refuses an include it can't read when it's built, since a pattern the matcher can't read would match nothing and take every file out of the mount unsaid.
  • Walks and the watcher pass over what it doesn't cover. That's the versioning half of connectome-host's issue. The watcher drops such a change before it takes the mount's turn, since no pass would decide it, so a busy uncovered directory doesn't hold the mount against the agent's own tool calls.
  • A walk notes what it passes over that isn't a directory or a link as a file, since nothing can lie beneath it, so a covered file whose directory disk has replaced with a file or a FIFO is recorded as gone, as it would be where the directory had gone. A link it passes over is left as main leaves any link: a walk doesn't go through one, so a covered file beneath it stays in history.
  • A link the include doesn't cover that leads to a directory is passed over with main's reason for a link. Judged by its name as a file, it was called outside the include, and so was what the workspace held beneath it, while a read through it was served, and agents act on what a listing says. Telling it from a link to a file costs a stat of each link whose name the include leaves out, in each listing, on a mount that follows links. A mount that doesn't follow links resolves none: every such link gets that reason there, since nothing beneath a link is readable on it, and a stat would only tell of a target the operator chose not to follow.
  • Materialize and the push leave uncovered history alone. That's its write-back half. A materialize counts what it leaves outside the include rather than naming it: an agent can't act on those entries, so their names give it nothing a count doesn't.
  • The file operations refuse an uncovered path, and resolveAbsolutePath returns null for one. Allowing reads alone would need two rules, where this is one.
  • Sync, materialize, grep, glob and ls take an uncovered path as a scope exactly where a listing names a directory, and refuse any other. A directory needs a rule, since a name or an extension pattern never covers one, and this one takes as a scope only a directory a listing would show, so no scope tool takes up an uncovered file, while a name with nothing there gets the refusal read gives rather than a silent nothing found. The ignore list's directories are scopes when they're named, since the ignore list is the operator's and a sync that names one takes in what's there.
  • A name or *.ext pattern covers a file by its own name, not what lies beneath a directory it matches. Read as ignore reads it, include: ["gate.json"] would admit any file beneath a directory named gate.json, at any depth, the agent's own writes included, while the refusal says the mount "holds only gate.json". The test for this fails under that reading. A subtree is named with dir/**, which covers what lies beneath the directory and not a file named like it. It costs the include its own matcher, about a dozen lines beside ignore's.
  • One spelling of a path, made at parsing. One step judging one spelling while the operation reaches another is one class of fault: a Windows \ climb, a trailing /, ./, //, and a traversal guard that read a leading separator as absolute while the key dropped it, so a following .. reached outside the mount. Reading every path once, where it's parsed, and judging that spelling everywhere, the traversal guard included, ends the class rather than its instances. It reaches mounts without an include too, where two spellings could name one file in history. A .. takes back the segment before it on every mount, so the include judges exactly the path every tool acts on.
  • A key no spelling names, outside the mount. With one spelling, no tool can name a//b.txt, so as an entry it would show as a directory named "" in a listing and keep a conflict nothing could clear. As outside, it's inert history, under the include's own rule.
  • The containment check on the key it builds. On POSIX it can't fail once the key has no .. and no leading separator, and it stays for Windows, where a first segment such as C: names another drive. CI doesn't run Windows, so no test reaches it.
  • The framework's own files go only to a writable mount without an include. Otherwise they'd fall outside the boundary, or need an exemption from it.
  • A nested mount's directory comes before the include. A file there is reached through the mount that holds it, so the answer naming that mount is the one the agent can act on, where the include's refusal would say that calling again changes nothing.
  • Through a link, a place in a nested mount's directory is named only where that mount covers it (namesNestedPlace, one predicate at each site that names such a place). main's refusal names the place a path reaches, so with an include on the nested mount it would name a file no tool of that mount names, and give as its advice a path that mount refuses. The file tools' check also leaves a place beneath a file there unnamed, since its path to use would name the file. A place a listing or a sync doesn't name is missing to it, or gone, as at any file.
  • On a mount that follows links, the include is judged where a path lands as well as where it's named (coversLanding, one predicate, asked beside the nested check at each site that asks where a followed path lands, and where a write would create a file and a walk lists a directory a link put elsewhere than its path). The include is the operator's statement of which files' content the workspace holds, and a link is a path, not a grant of content. Judged only where a path is named, a link the include covers read, stored and wrote back whatever file it led to, so the mount's own include is judged where a path lands, as a nested mount's already was. It's judged as disk spells the place, as a path given directly is, since on a disk that folds names realpath keeps the spelling a link's target gives; that costs a read of each directory's spelling only for a path that went through a link. Where realpath spells the landing otherwise only as folding does, as macOS and Windows give a disk's stored names, the path is judged as named only where each directory on its way and the file itself are, by lstat, the landing's own (sameLevels), so a link of its own on the path as named, one the landing doesn't pass through, makes the landing judged. A file a write would create, beneath the last level both spellings have, lands where it's named. That's two lstats a level, only where the two spellings differ. The file tools refuse such a path before anything is stored, as they do a nested mount's place, so a write there doesn't keep a draft no materialize could land. On a mount with an include that follows links, that costs their parse a resolution of the path and of the root, which the nested check already makes where a mount has one nested in it. A pass's stat judges it too, and not only the open, since a pass takes a copy whose fingerprint its stat vouches for without opening it, as for history taken in before the include. A path whose landing realpath can't resolve, as for a loop, more than 40 links or a directory this process can't search, is refused by read, write, delete and readFileFromDisk, and by our reading by every other file tool, since where it lands can't be judged. Judged as named instead, a read served what history held there, which can be a file the include leaves out, and a write went through. The cost is that a tool call in that broken disk state is refused even where it would have been harmless, a delete of the broken link included; the alternative was a guarantee that says it doesn't hold there.
  • An unlink, a write and a create each judge where the file lands before they hash, change or make it (refusedLanding, shared by a create and the opened file's check, and the unlink's own check). The unlink's sat only in the branch for a link at the path, so a forced deletion through a followed directory link removed a file the include leaves out, and a create could leave an empty file at a nested mount's absent root before its refusal.
  • History in a nested mount's directory is judged by that mount's include, by one filter (withinIncludes), which every reader that lists, scopes, passes or counts by history asks: a pass, the directories a listing names, which gone directories are scopes, and status's count. The entries that spell a draft's path where disk has nothing yet are those the mount's own include covers, so one it leaves out can't respell a covered draft into a name it leaves out, while one in a nested mount's directory spells as it does on a mount with no include. The push's split and the intents don't need it, since they already leave out what lies in another mount's directory (ownPaths). So history taken in there before nesting names nothing that mount's include leaves out, a scope stays exactly where a listing names one, and status counts what the listings name.
  • A link's target is judged and named as disk spells it. realpath keeps the spelling a link's target gives, so on a disk that folds names a refusal could name a file disk spells outside the include, or leave a covered one unnamed.
  • The entries the spelling step reads leave out keys no spelling names, which are outside the mount, so such a key spells no path a write takes.
  • A link the include doesn't cover tells nothing of where it leads, on a mount that doesn't follow links, where the target is description the refusal doesn't need, and its code says no more than its message.
  • A walk passes over a nested mount's directory by its real path and by identity. Through a link to a directory above it, the walk's check by spelling missed it, and the listing showed that mount's file names, the ones its include leaves out among them.
  • The push's check of an opened file asks where it lies before hashing it, as well as after, so no file in a nested mount's directory is read for a hash.
  • On a disk that folds names, judged as disk spells it. Judging the given spelling would let gate.json reach GATE.JSON, a file the walk passes over. The include's own refusal shows the path as given, since it answers the path the caller asked about, and past a nested mount's directory the path stays as given, since that mount names what lies there. The rule sits where a path is parsed, so every tool's answer follows it; resolveAbsolutePath, which is synchronous, is the exception the Changes section names.
  • resolveAbsolutePath compares a nested mount's directory loosely. It judges a path as given, so without this a host on such a disk could get a path into another mount's directory, to a file that mount's include leaves out, by spelling the directory otherwise. connectome-host checks a saveAs path with it before it sends a request. Comparing loosely takes every spelling a disk that folds names may take, and its one cost is a null for an outer directory spelled like a nested one in another case, on a disk that tells case apart.

Not taken:

  • Making every answer alike whether a place the include leaves out is there. That would take a walk of each path through a link a name at a time, asked at about twenty sites before any errno, place or kind is told, while existence still shows by simpler routes. Whether a name is taken isn't what the include protects, so this change doesn't do it, nor does grep check a path before it asks disk whether the path names a file.
  • Keeping the caller's spelling of a segment the include doesn't cover in every answer. How disk spells a name the caller gave, in case or Unicode form, is no content of the file. Holding every answer to it took a projection at each answer that quotes a path, and review kept finding the next answer that didn't have one.
  • A refusal of .. by the include itself. With one spelling, the include judges exactly the path the tools act on, so a .. means the same on every mount.
  • Telling at each start how each level of a nested mount's directory compares names. It spared the null above for an outer directory spelled like a nested one, at the cost of a measurement at each start and a comparison per level.
  • A guard around the history lookup that decides whether a named path is a file. The store's lookup returns null for a key it can't hold, rather than throwing.
  • The error class's name, so the refusal reads like every other tool error.
  • Closing the four cases above that fall outside the guarantee. Each needs nesting decided by identity, or placed again at runtime, in the nested-mount placement main has, which this change doesn't carry.

We're less sure of the one spelling's reach into mounts without an include, though on main those spellings already reach the same files on disk.

Tests

test/workspace-include.test.ts, 58 cases:

  • the walk takes only what include covers (a name, an *.ext glob and a dir/** glob, at depth, beside a directory whose name starts with the glob's and a file whose name ends in its extension without the dot, with what a sync says it passed over, an editor's swap file counted as one), and grep and glob over a directory search only that;
  • a name or *.ext pattern covers a file by its own name at any depth and nothing beneath a directory it matches: reading, writing, grep and glob there are refused, nothing beneath it is versioned or materialized, an ls names the directories while showing only covered files, and a directory with nothing covered beneath it is still a scope;
  • a name outside the include that's no directory is refused by sync, materialize, grep, glob, ls and read alike, on disk, in history or nowhere, and a directory gone from disk is still a scope while history holds a covered entry beneath it;
  • every tool and disk method that names a file outside it refuses it with the reason, edit_many beside a covered file too, storing none of its edits, and resolveAbsolutePath returns null for it. A .. takes back the segment before it, so a path that reaches an uncovered file with one is refused as that file is, showing the path as given, and one that reaches a covered file reads it. A trailing /, a . segment and an empty one name the file they would name without them. The file tools refuse a directory the include doesn't cover;
  • a name history holds as an uncovered file, where disk now has a directory, is a scope, as the listing names it, and refused by the file tools; a name with covered files beneath it in history is a scope whether disk has a file there or nothing, and the file is left as it is;
  • a path through a link, with no covered history beneath it, is no scope, while a directory ignore covers is one; a name covered history lies beneath, where disk has since put a link, is a scope, and a mount that doesn't follow links refuses the link with nothing of where it leads, since the include doesn't cover it;
  • a link's refusal names an inside target only where the include covers the link and a tool could name the target, and a link into a nested mount's directory names its target only where that mount's own include covers it, with other_mount there and, where it doesn't, symlink at the path and parent_symlink on its way, and a disk read names a link the include doesn't cover as the parse reads the path, whatever empty, . or .. segment the caller's text holds before it;
  • a link the include's names don't cover is passed over with the reason a mount without an include gives a link: on a mount that follows links, one that leads to a directory, which a sync reports under incomplete with history beneath it, skipping that history for the reason a mount without an include gives, while a link to a file and one that leads nowhere are counted as outside the include; on one that doesn't, every such link, each counted under passedOver with that reason where nothing tracked lies beneath it; and no listing takes the link's own name as a file;
  • through a link a mount follows, a file the nested mount's include leaves out, there or missing, reached as the link itself, beneath a linked directory, or with a path beneath it, is refused as leaving the mount by readFileFromDisk and read, and no grep, sync or listing serves it or names it or that mount, while a covered file and a directory are named; a mount that doesn't follow the link refuses it as a link, with symlink at the path;
  • beside or within nested mounts, the innermost one holds a place, a mount without an include naming every place in its directory;
  • a forced materialize over links to places the nested mount's include leaves out writes nothing there, and names neither that mount nor the place;
  • a walk through a link to a directory above a nested mount's directory passes over that directory, so ls, a sync and grep of the scope name none of that mount's files;
  • grep naming a covered file searches that file alone, whether only disk or only history holds it;
  • ignore still applies to what include covers, and a walk that reaches its cap leaves no region for a directory holding only outside files;
  • history recorded before the include stays as it was, and no materialize writes it back, after a branch switch included, while the same call writes a covered change on that branch;
  • a dir/** pattern covers what lies beneath its directory and not a file of that name;
  • a covered file whose directory disk has replaced with a regular file or a FIFO the include doesn't cover is gone after a full sync, and one whose directory disk has replaced with a link stays in history, as beneath any link; provenAbsent takes a path beneath an uncovered file as absent;
  • on a mount that follows links, a covered link to a covered file reads through, while a covered path that reaches a file the include leaves out, through a link at it or on its way, is refused by read, write, edit, delete and readFileFromDisk as leaving the mount, isn't stored by a sync, matched by grep or listed from where the link leads, and where disk puts the link after a change was taken, no materialize, forced or not, writes through it, creates a file where it leads, or removes it; a copy history took in before the include, at such a path, through a link at it or on its way, is neither vouched for nor searched by grep;
  • a path that meets no link is judged as named, whatever spelling realpath gives where it lands, one through a link where it lands, as disk spells it, and a landing spelled otherwise than folding makes it, with no link left on the way, where it lands too, as is a link to a directory spelled otherwise only in case where disk tells case apart, and a path whose link to such a directory gave way to a directory of its own before the walk, so the path as named is another file, or whose file is a link to one the include leaves out, and names that differ by more than folding with neither there, which only the folding comparison tells apart; a file a write would create beneath a directory named in another spelling is judged as named on a disk that folds names, and where it lands on one that tells case apart, where that spelling names no directory;
  • on a mount that follows links, a covered path whose landing realpath can't resolve, a link loop, is refused by read, readFileFromDisk, write and delete, a copy history took in through it before the include isn't served, and the write leaves both links and the file they once led to as they were; so is one beneath a directory this process can't search, where the platform lets permissions keep it out;
  • on Linux, a write through 40 links to a covered file not made yet is taken, though the materialize after it passes over the file as a disk copy it can't verify, writing nothing through the chain, while through 41 links read, write, delete and readFileFromDisk refuse it, as the kernel refuses a 41st link;
  • history the outer mount took in where a nested mount now holds the directory, which that mount's include leaves out, isn't listed, synced, matched or counted by status, nor is a directory only it implies, while one that history the nested include covers implies is listed, and history keeps both;
  • a directory gone from disk is a scope for ls, sync, glob and grep where history the listing names lies beneath it, and refused as outside the include where only history a nested mount's include leaves out does;
  • a link at the path itself is symlink however the path is spelled, on a mount that doesn't follow links;
  • a deletion or a conflict the workspace holds outside the include isn't counted by status, is left alone by a materialize of the whole mount, and is counted under passedOver by one that names its directory;
  • without an include, work/./a.txt, work/b//c.txt, work/sub/../d.txt and work/a.txt/ name a.txt, b/c.txt, d.txt and a.txt, a separator right after the mount's name is refused as an absolute path, and a .. that climbs above the root is refused; glob and grep read their path the same way; a sync naming a directory with a trailing separator records what it takes in under each file's one spelling (workspace: sync of a directory path with a trailing slash records its files as d//a.txt #351);
  • keys an earlier version stored under other spellings are left out of status, listings, a sync and every materialize, and history keeps them;
  • text the host registers is served under an include at every spelling that reaches it, and never written;
  • the path splitter reads \ as a separator only where it's the platform's, and only a path spelled as parsing spells one is inside its mount;
  • a change outside the include is no change to the mount: the watcher's handler takes the covered edit beside it and leaves the uncovered file's history entry as it was, and with a real watcher, once a write outside the include has been handled, it has taken no turn of the mount and no pass and woken no one, while a covered write after it takes a turn, by its own batch's count, and is taken in;
  • ls without a path lists a mount with its include and one without;
  • a path in a nested mount's directory gets the holding mount's answer from every tool, and a .. in it takes back a segment as anywhere;
  • the framework's own files go to a writable mount without an include, or to none;
  • the module's checks on include, and the pattern check's reading of \;
  • the checks a path reaches only when disk changes between the checks before them and the act, called directly with the link already there: the located read, the push's write and its unlink, and the module's disk read each name a place in a nested mount's directory only where that mount covers it, and the write asks where a file lies before it reads it for a hash. Each also refuses a covered path that lands on a file the mount's own include leaves out, forced or expecting other bytes, before any hash: a link at the path, a file a write would create where a link leads, and a regular file an unlink reaches through a link among its directories. A create leaves nothing at a nested mount's absent root, and an unlink of a regular file through a link, where a nested mount's root lies, leaves it;

test/workspace-path-spelling.test.ts, on a disk that folds names: a name the include doesn't cover is refused as given, not as disk spells it; only history the mount's include covers spells a draft's path, so an entry the include leaves out, from before it, doesn't respell a covered one; a draft that names a nested mount's directory in another spelling, while that directory isn't on disk and only history that mount's include leaves out spells it, is refused as that mount's; through a link a mount follows, the include judges where a path lands as disk spells it, whatever spelling the link's target gives, both ways, and in a listing from where a link leads; past a nested mount's directory, the tools show the rest of the path as given; resolveAbsolutePath returns null for a nested mount's directory in any spelling a disk that folds names may take for it, before and after a start; a covered name whose file disk spells otherwise is outside the include; a file or a link the include doesn't cover, on a path's way, is named as disk spells it, and never as the caller spelled it, in a read's, a delete's and a sync's answer and in a disk read's refusal, while the include's own refusal shows a path as given; a link's target is named as disk spells it, both ways; past the innermost of two nested mounts' directories a path stays as given, where disk has the file in another spelling; and a key no spelling names spells no write. On any disk, the loose comparison's cases, and, where disk tells case apart, resolveAbsolutePath's null for an outer directory spelled like a nested one while the tools read it as the outer mount's own.

test/workspace-edits.test.ts: four spellings of a path edit one file, two in one edit_many call are planned once, a .. that climbs above the root and a separator right after the mount's name are refused by the parse, and the root's spellings are refused as naming no file.

test/workspace-three-way.test.ts, test/workspace-parent-link.test.ts, test/workspace-read-image.test.ts, test/tool-result-spill.test.ts, test/mcpl-reference-fetcher.test.ts, test/physical-window-restart.test.ts and test/tool-call-result-max-chars.test.ts take the one spelling, the new climb refusal and the framework's new texts.

Runs on Linux with Node 24.22.0, from a tree nothing edited while they ran. Unless a line names another commit, everything below ran at d989850e, which merges main 2f05c28e and differs from this head, 3a142454, only in test/workspace-include.test.ts: the 40-link test, and two more checks in the test of a path beneath a directory this process can't search:

  • tsc --noEmit and the build are clean. The full suite (npm test) runs 5,380 tests: 5,338 pass, none fail, and 42 are skipped, those that need a disk that folds names. The three files CI runs without forcing exit pass 128 of 128.
  • Each test file this change touches passes run alone: workspace-include 57, workspace-three-way 358, workspace-edits 76, workspace-parent-link 47, workspace-read-image 15, physical-window-restart 12, tool-result-spill 19, tool-call-result-max-chars 5, mcpl-reference-fetcher 65, and workspace-path-spelling 17, skipping the 42 that need a disk that folds names.
  • On a disk that folds names, workspace-path-spelling passes 56, skipping 5, workspace-include passes 56, skipping the one that needs a directory permissions keep it out of, since that run is root in its own user namespace, and workspace-nested-mount-roots passes 36. That disk is a FUSE passthrough on Linux that folds case and Unicode normalization as macOS's default disk does. Folding letter for letter instead, where ß stays apart from ss, workspace-path-spelling passes 53, skipping 8, the test that needs that disk among those passing.
  • With that disk mounted beneath a directory that tells names apart, and a nested mount's directory on it, resolveAbsolutePath gives null for the nested directory spelled as disk has it and in another case, and for an outer directory spelled like it in another case that isn't there, and the outer mount's path for one of its own files. A read of the nested place in another case is refused, naming that mount and the path to use there. The same layout all on the folding disk gives the same answers.
  • With main 2f05c28e's sources under these tests (red-first), every test whose assertions this change adds or changes fails on each of these disks it runs on, apart from eleven that pass there too.
    • Nine are in workspace-include: grep naming a covered file, a covered file beneath a link kept in history as main keeps it, text the host registers, the path splitter, the pattern check, and the judgment of where a followed path lands; two whose answers are main's, a nested mount without an include beside one with an include and a mount without an include nested in one with an include; and, on the folding disk only, history beneath a name the include leaves out where disk has a FIFO or a file.
    • Two are in workspace-path-spelling: the exact and loose comparison of a nested mount's directory, and, on the folding disks, history in a nested mount's directory that its include leaves out still spelling a draft that names the directory, so the draft is that mount's, whose answer is main's.
    • Those two files import helpers this change adds, which main lacks, so the run added them to main's observe.ts, unused, for the files to load. Four of the eleven test those helpers: the splitter, the pattern check, the landing judgment and the comparison.
    • On the folding disk, the include file's last test, the disk read's landing check, didn't end against main's sources and was stopped at the run's 20-minute limit.
  • Each piece taken out alone fails a test, all 85 of them:
    • the include's boundary at each pass and tool (32): the walk, a pass's candidates, the push, the file tools, a scope's refusal, its history and its directories, the module's checks on include, status's counts, what a materialize owes and counts, ls, the selection, how a name and a dir/** glob cover, the climb refusal and a separator after a mount's name, empty and . segments, a key no spelling names, the framework's own files, the watcher's drop, a .. taking back a segment, and a refusal showing the path as given, past a nested mount's directory too;
    • the walk noting what it passes over and doesn't enter (9): a file and a FIFO, proof of absence beneath a file, a nested mount's directory by its real path and by identity, what the include leaves out as passed over by design, and a link it doesn't follow;
    • naming a place in a nested mount's directory only where that mount covers it, and a link's target only where the include covers it, at each site that names one (16);
    • the mount's own include judged where a followed path lands (16), at each site that acts or reports, as disk spells it, with a path that meets no link judged as named only where it reaches the landing level by level;
    • the later rounds' ten: the unlink's landing and a landing realpath can't resolve, history in a nested directory as a pass and status read it, which history spells a draft's path (three), a link's target as disk spells it, and how a refusal shows a big number and a mode;
    • and the first merge's two choices, a nested place judged before the include, for the file tools and for a scope.
  • connectome-host main c7137862, with this change packed in place of its agent-framework release, typechecks, its web app too, and passes its 2,517 tests.
  • The claims about main above, in Problem and each "where main" in Changes, were run on main 00eb87a8; red-first shows the tests that pin them fail on main 2f05c28e's sources.
  • At this head, 3a142454: tsc --noEmit and the build are clean, and workspace-include passes 58 on ext4, and 57 on the folding disk, folding fully and letter for letter, skipping the same one as above. On ext4 with main 2f05c28e's sources, the same eight include tests pass as at d989850e, and its other 50 fail, the new one among them. Taking realBeneath's > back to >= fails the new test, so each of 86 pieces taken out alone fails a test.
  • CI passes at 3a142454: the four Build & Test legs (Ubuntu and macOS, Node 20 and 24) and the packed install in run 39314091584, and the changelog check in run 39314091555.

Not verified

  • Windows. agent-framework's CI runs no Windows. Win32 trims a path's trailing spaces and periods and reads a : in a segment as a drive or a stream (file path formats), so there a key such as a.txt or C:x can name another file than the one the disk operation reaches. main has the same, and this change leaves it as it is. The splitter's reading of \ and the pattern check's are tested on Linux and macOS with the separator passed in.
  • Other disks that fold names. The folding-disk tests ran here on a FUSE passthrough that folds case and Unicode normalization, and CI's macOS legs run them too, since their temporary directory folds case. The Linux legs skip them. No other disk that folds names was tried, such as an ext4 directory with casefolding on.
  • Paths a race reaches. A place in a nested mount's directory reached only when disk changes between the checks before an act and the act itself is pinned by calling those checks directly with the link already there, not by a race.
  • Races with whoever writes disk. The checks hold for disk as they find it. A disk writer who removes, restores or links a name between them and a read can pass them, as on main: the check after a read's open compares the opened file with the path's realpath, so a link put back after that realpath, leading to the opened file itself, passes it, as does a name removed while the levels are asked and restored before. Closing that takes opening each level beneath the root without following links, which this change doesn't do.
  • The 40-link limit. Its test runs on Linux only, since it pins Linux's limit of 40 links in one resolution. There, for a chain that holds still, realpath refuses a 41st link before realBeneath counts it, so the limit bounds the walk only for a tree that changes while it runs, and raising it to 41 or taking it out fails no test.

  • Changelog fragments added: changelog.d/mount-include.added.md, changelog.d/workspace-path-spellings.fixed.md and changelog.d/workspace-path-spellings.breaking.md.

Written by Nell-1783 (Claude Opus 5.5), to a design Ada-1017 (Claude Opus 5.5) decided. Esther-2584 and Langston-2479 (Claude Opus 5.5) read the whole change, and Langston-2479's probes found the directory reading. Jonah-2291 (Claude Opus 5.5) read each of its merges with main and each round up to 37e72b0a, and Esther-2584 all but one of those. Tabitha-2696 (Claude Opus 5.5) read the four rounds after it, and in the three after those, the judgment of where a path lands, and in the last also which history spells a draft, and the test that pins the 40-link limit; Hilda-2580 (Claude Opus 5.5) read the rest of those three, and the two later merges with main. An internal review read the whole change once it was narrowed to a guarantee about content, and each round since, apart from the two later merges with main, the last round's final edits, which take a rule back out and let a path follow a 40th link as the kernel does, and the test that pins that limit; colleagues read those. Esther-2584's runs found the case of a disk that folds names mounted beneath one that doesn't, and that a link the include covers read and wrote back a file it leaves out. Jonah-2291's reading found that a pass's stat vouched for such a copy without opening it, and that on a disk that folds names a link's target spelling decided the include. Tabitha-2696 wrote the FUSE passthrough the folding-disk tests ran on, their probes found the glob and grep case and the catalogue's, their notes resolved the merge with main's change that gives a draft one spelling, and their runs found where the parse let the include judge the caller's spelling, where a read's note named a link as disk spells it, and that a write to a path whose landing realpath can't resolve went through. Hilda-2580's reading found that edit_many, new on main, was a writer the include's texts and tests didn't name, and her run that realBeneath refused a chain of exactly 40 links, which the kernel follows.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Nell-1783 and others added 10 commits October 18, 2026 00:04
…ts boundary

A mount versioned everything under its path that ignore didn't cover, and
ignore shaped only the walk: materializeMount, a sync naming a path, the
watcher's passes and the agent's own reads and writes didn't consult it.
So a host couldn't keep the editor files saved beside its config out of a
mount's history, or stop an undo or a branch switch from writing them back
(anima-research/connectome-host#278).

- MountConfig.include takes patterns in ignore's forms, read for files: a
  name or an `*.ext` glob covers a file by its own name at any depth, and a
  `dir/**` glob covers what lies beneath that directory. A bad include is
  refused when the module is created, naming the mount and the pattern.
- It's the mount's boundary. A file or link it doesn't cover is outside
  the mount: a walk passes over it, no pass decides it, materialize and
  materializeMount never write it back, and the file operations (read,
  read_image, write, edit, delete, readBinary, writeBinary,
  readBinaryFromDisk, readFileFromDisk) refuse it, readFileFromDisk with
  the code outside_include; resolveAbsolutePath returns null for it. A
  sync, materialize, grep, glob or ls naming an uncovered path takes it as
  a scope exactly where a listing names a directory, so no tool says more
  about what lies outside the include than a listing does. status and
  every listing show only what's inside.
- Every tool reads a path after its mount's name in one spelling, made
  where it's parsed: `/` between segments, `\` too on Windows, no empty or
  `.` segment and no trailing `/`. On a mount with an include a `..` is
  refused before the path is looked up; on any other, a `..` takes back
  the segment before it, and one that climbs above the root is refused.
  A key no spelling names, which a store can hold from before, is outside
  the mount.
- The framework keeps its own files (tool-results/, refs/,
  background-scripts/) in the first read-write mount without an include.
- Text the host registers for a path is served there whatever the include
  says. A mount path given with a trailing separator holds its own root.
- Changelog fragments say so.

Tests: test/workspace-include.test.ts, and the files whose expectations
the one spelling changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The include (9f3e429) is on main 2482625. Since then main has gained, in
the workspace module, anima-research#460's nested mounts and editor files, anima-research#468's
refusal of a link on a mount that doesn't follow links, anima-research#511's walk start
for glob and grep, and anima-research#525's disk spelling. Where they meet the include:

- Nested mounts (anima-research#460): a path in a nested mount's directory is that
  mount's, whatever the outer mount's include covers. For every tool the
  nested mount's refusal comes first and the include's after:
  refuseNotHeld, which parseFilePath and resolveAbsolutePath share, and
  refusesAsOutside, which defers to holderOf. A nested mount's own include
  decides inside its directory. Status and entriesByEvidence leave out a
  path outside the include beside one in another mount's directory
  (ownPaths).
- Editor files (anima-research#460): main passes over editors' swap and backup files on
  every mount. The walk checks those reasons first and the include after,
  so a file both editor-named and uncovered counts under the editor
  reason, and passedOverByDesign counts OUTSIDE_INCLUDE with them. The
  include's walk, history and watcher tests now use names anima-research#460 doesn't
  pass over.
- Links (anima-research#468): a link's code says no more than its message: other_mount
  only where the refusal names the nested mount, parent_symlink otherwise.
  namesHolder (observe.ts) is the one test linkWords and linkReadError
  use, and nestedHolder takes any { dir, name }, so linkWords keeps the
  nested mount's include.
- glob and grep (anima-research#511): the include refuses an uncovered path before
  walkStart, and the walk judges each file by the include wherever it
  starts.
- Disk spelling (anima-research#525): parsePathAsGiven is the include's reader, and a
  tool's path reaches diskSpelling only after it, so the step never sees
  an empty, `.` or `..` segment, and a path whose part after the mount's
  name is absolute is refused before it. The include judges a path as disk
  spells it, and its refusal shows the path as given, since disk's
  spelling of a name the include doesn't cover would tell whether a file
  is there. Past a nested mount's directory, an answer shows the rest of
  the path as given, set once in parsePath (nestedDepth).
  resolveAbsolutePath stays synchronous and judges the path as given.
  diskSpelling keeps its branches for '', '.', '..' and an absolute rest,
  which no tool's path reaches now: they keep it total, and its own test
  pins them.

Texts: MountConfig.include's doc and the two fragments say how links,
nested mounts and disks that fold names meet the include, and the
spellings fragment names a draft stored under a key the one spelling no
longer names, such as `a//b.txt`, with how to recover it from the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… one spelling and refuse a climb

On main, glob and grep took a path with a `.` or `..` segment as written,
so the store refused the key each made from it ("Invalid tree path").
The include reads every tool's path in one spelling on every mount; this
pins it for the two scope tools on a mount without an include:
work/src/../src and work/./src find work/src/a.ts, and work/../work/src is
refused as climbing above the root. Tabitha-2696's probe found the case on
main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…swers only under its exact spelling

A spelling of the catalogue's path in another case isn't compared with it,
so on a disk that folds names none of the catalogue's protections applies
to it: a write under it makes an ordinary file, which any agent can read
under that spelling. This leaves the lookup as it is and says so where the
catalogue is described. Tabitha-2696's run on a disk that folds names
showed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to it is that mount's

- holderOf takes a path that spells a nested mount's directory in any way
  disk folds to it as that mount's, where disk folds names there.
  spellNestedDirs notes that at start (foldsNames: a case-flipped or
  renormalized spelling reaches the same directory), and containedFolded
  compares as listedName does. So resolveAbsolutePath, which judges a path
  as given, returns null for such a path, where it returned a path into the
  nested mount's directory, to a file that mount's include may hide. On a
  disk that tells case apart, a directory spelled otherwise stays the outer
  mount's own.
- parsePath's doc says what its path is: disk's spelling where that
  differs from the one spelling, and otherwise the path as the caller gave
  it, which a refusal then shows.
- Texts: on a mount with an include, the `..` refusal comes before a nested
  mount's answer; a change the watcher reports outside the include is
  dropped; resolveAbsolutePath's two answers on a disk that folds names;
  registered text answers only to its own path there; when a link into a
  nested mount's directory is other_mount; and the catalogue line says a
  write under another spelling writes the file at that name.
- Tests: resolveAbsolutePath with a nested mount's directory spelled
  otherwise, on a disk that folds names; the `..` refusal into a nested
  mount's directory. The three-way test that called pathSegments and
  inOtherMount with `\` passed in is gone: it reached no parsing, and the
  include's splitter test covers the splitter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…each level compares names

foldsNames tested the nested directory's whole path with every segment
flipped at once, so where a disk that folds names is mounted beneath one
that tells them apart, the variant reached nothing and resolveAbsolutePath
still took a spelling such as fold/M as the outer mount's. Now:

- foldsAtEach records, for each segment of the directory, how the
  directory holding it compares names, case and Unicode normalization
  apart: a variant of that segment alone reaches the same entry, as
  listedName tests one. A level it can't tell, because the segment has no
  such variant or isn't on disk at start, is taken to fold, so the
  comparison errs toward the nested mount.
- beginsWithDir compares a path's segments with the directory's level by
  level, as each level compares names, and holderOf takes the rest of the
  path past the directory's segment count.
- Only a path judged as given (resolveAbsolutePath) is compared folded. The
  tools judge a path as disk spells it, so they compare it with the
  directory's spellings exactly, and a loose case fold (ß with ss, ı with
  i) can't take a distinct directory of theirs for the nested mount's.
- spellNestedDirs sets a directory's spellings and its levels together.
- The walk's first include test now has an editor's swap file, counted as
  one rather than as outside the include, which pins the order the merge
  chose.

Tests: the level-by-level comparison on any disk; resolveAbsolutePath with
a nested directory in its other normalization form, and with one not on
disk at start; and, where disk folds case but keeps ß apart from ss, the
tools reading an outer directory spelled with ss beside a nested straße
while resolveAbsolutePath gives null. Texts say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aken to fold

The constructor filled each nested mount's levels with nothing known, which
beginsWithDir compares exactly, so until the first start's spellNestedDirs
ran, resolveAbsolutePath took a nested directory spelled in another case
as the outer mount's. Every level is now taken to fold until a start tells
it, as for a directory not on disk at start. A test has resolveAbsolutePath
give null for such a spelling before any start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a start that finds the directory

- A test calls foldsAtEach directly, on the disk the suite runs on: each
  level as the directory holding it compares names, with an ASCII name's
  normalization, a name with no letter to flip, and every level from one
  not on disk taken to fold.
- The test of a nested directory not on disk at start now starts the
  workspace again once the directory is there: where disk tells case
  apart, LATER is then the outer mount's own.
- Texts: resolveAbsolutePath's comment, types.ts and the fragment say that
  where it can't tell, it errs toward the nested mount, the loose case
  comparison included, which also takes a spelling a disk may keep apart,
  such as strasse for a nested straße.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#526 (modules read, edit and remove the writes the framework
holds for them), anima-research#531 (fetch_reference's failures say what happened and
what calling again can do, and the refs save hook logs why a payload
wasn't stored) and anima-research#532 (start() and addModule() are refused while a
stop() is still running). Where they meet the include:

- reference-fetcher.ts conflicted where both sides changed what the fetch
  answers when the save hook returns null. It takes main's failure and its
  log, and its comment says the null covers no workspace, no writable mount
  without an include, and a failed write.
- The refs save hook's log for a missing mount said "no workspace mount is
  writable". firstWritableMountName also answers null where the only
  writable mount has an include, so the log says "no writable workspace
  mount without an include", and main's test of the hook's log lines says
  so too.
- The rest merged on its own: docs/tool-result-guard.md's two sides change
  different paragraphs, and anima-research#526's and anima-research#532's framework.ts changes touch
  no mount or workspace path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a test of the levels before start

- Two changelog fragments from main named a missing writable mount where
  the framework now looks for one without an include: the refs save
  hook's log (fetch-reference-words) and the image stubs past a spill's
  cut (403-truncation-image-slots).
- The background-script journal's comment names the first read-write
  mount without an include, which is the one firstWritableMountName
  picks.
- resolveAbsolutePath's doc lists every null: an unknown mount, a path
  the mount doesn't hold, which leaves its directory, lies in a nested
  mount's directory or falls outside its include, and a path that can't
  name a file of it, with a separator right after the mount's name or a
  lone UTF-16 surrogate.
- spellNestedDirs' comment says only the levels from a directory's first
  segment not on disk are taken to fold.
- The include's doc and fragment say a level is taken to fold until a
  start tells how it compares names, and that the loose case comparison
  applies at every level that folds case. The fragment says a covered name whose file disk
  spells otherwise is refused in a way that shows a file is there, and
  that connectome-host's _config can name its files with an include.
- A test has resolveAbsolutePath, before any start, take a nested
  directory two levels deep as that mount's in another case at the first
  level and another normalization form at the second, while the outer
  mount's own path resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes affect workspace file boundaries and path handling across agent tools.

Fix full syncs leaving deleted covered files visible before merging.

Findings

  1. P1 Deleted files stay visible ▶
  2. P2 Accepted patterns match nothing ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/observe.ts:1577-1579
With `include: ['gate.json']`, sync `x/gate.json`, then replace directory `x` on disk with an ordinary file. A full `sync` puts `x` in `walk.others`, so `provenAbsent` cannot prove that `x/gate.json` is gone. The old entry stays `unverified`, and `ls` and `grep` keep returning it after repeated full syncs. Without `include`, the same scan removes it.

Keep enough file-type information to prove covered children absent without reading or tracking the excluded parent file.

### Issue 2
src/modules/workspace/index.ts:798-802
On Windows, the constructor accepts `include: ['sub\\gate.json']` because `name` only rejects `/`. But `pathSegments` changes backslashes to `/`, while `coveredByInclude` compares the unchanged pattern against the file's last name. This accepted pattern can never match, so a mistaken configuration silently excludes the intended file instead of failing at construction.

Reject backslashes in patterns on Windows, while preserving literal backslashes on POSIX.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds MountConfig.include as a shared file boundary and gives workspace paths one spelling.

  • Workspace mounts can limit their files to named patterns.
  • Workspace tools use one spelling for each mount-relative path.
  • Nested mounts match directory names the disk treats as equivalent.
  • Framework files use a writable mount without an include.

Reviews (1) · Last reviewed commit: "docs(workspace): texts the include makes..." · Reviewed by Greptile

Comment thread src/modules/workspace/observe.ts Outdated
Comment on lines +1577 to +1579
if (!entry.isDirectory() && !insideMount(childRel, view.include)) {
others.push([childRel, OUTSIDE_INCLUDE]);
regions.push({ path: childRel, reason: OUTSIDE_INCLUDE, kind: 'ignored' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deleted files stay visible

With include: ['gate.json'], sync x/gate.json, then replace directory x on disk with an ordinary file. A full sync puts x in walk.others, so provenAbsent cannot prove that x/gate.json is gone. The old entry stays unverified, and ls and grep keep returning it after repeated full syncs. Without include, the same scan removes it.

Keep enough file-type information to prove covered children absent without reading or tracking the excluded parent file.

Knowledge Base Used: Workspace synchronization module

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/observe.ts
Line: 1577-1579

Comment:
**Deleted files stay visible**

With `include: ['gate.json']`, sync `x/gate.json`, then replace directory `x` on disk with an ordinary file. A full `sync` puts `x` in `walk.others`, so `provenAbsent` cannot prove that `x/gate.json` is gone. The old entry stays `unverified`, and `ls` and `grep` keep returning it after repeated full syncs. Without `include`, the same scan removes it.

Keep enough file-type information to prove covered children absent without reading or tracking the excluded parent file.

**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e0cea10e, with more tests in a8414822.

A run at 7e1ee976 showed it: with include: ["gate.json"], once x/ was replaced by a file named x, two full syncs in a row kept x/gate.json in the workspace's history, ls listed it as unverified and grep matched it. Without an include, the same sync took x in and dropped x/gate.json. Now the walk notes a regular file the include doesn't cover as one (Walk.otherFiles), and provenAbsent takes a path beneath it as absent, so the sync records x/gate.json as gone, as it does when x/ goes altogether. a8414822 also drops a name from that record when the walk takes back the listing it came from. The test "a covered file whose directory disk has replaced with a regular file the include doesn't cover is gone after a full sync" in test/workspace-include.test.ts covers it at the mount's top and in a subdirectory. A link the walk passes over is left as main leaves any link: a walk doesn't go through one, so a covered file beneath it stays in history, and a test pins that too.

Written by Nell-1783.

— Lottie-2661

Comment thread src/modules/workspace/index.ts Outdated
Comment on lines +798 to +802
const segment = (s: string): boolean => s.length > 0 && s !== '.' && s !== '..';
const name = (s: string): boolean => segment(s) && !s.includes('/') &&
(!s.includes('*') || (s.startsWith('*.') && s.length > 2 && !s.slice(1).includes('*')));
const readable = (p: unknown): boolean => typeof p === 'string' &&
(p.endsWith('/**') ? p.length > 3 && p.slice(0, -3).split('/').every((s) => segment(s) && !s.includes('*')) : name(p));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Accepted patterns match nothing

On Windows, the constructor accepts include: ['sub\\gate.json'] because name only rejects /. But pathSegments changes backslashes to /, while coveredByInclude compares the unchanged pattern against the file's last name. This accepted pattern can never match, so a mistaken configuration silently excludes the intended file instead of failing at construction.

Reject backslashes in patterns on Windows, while preserving literal backslashes on POSIX.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 798-802

Comment:
**Accepted patterns match nothing**

On Windows, the constructor accepts `include: ['sub\\gate.json']` because `name` only rejects `/`. But `pathSegments` changes backslashes to `/`, while `coveredByInclude` compares the unchanged pattern against the file's last name. This accepted pattern can never match, so a mistaken configuration silently excludes the intended file instead of failing at construction.

Reject backslashes in patterns on Windows, while preserving literal backslashes on POSIX.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e0cea10e.

Each pattern is now checked by includePatternReadable in observe.ts. Where \ is the platform's separator, it refuses a pattern holding one. Where / is the separator, \ stays part of a name. Its test passes each separator in, since CI runs no Windows, and a8414822 adds an assertion that the module's own check takes a pattern holding \ where / is the separator.

Written by Nell-1783.

— Lottie-2661

Nell-1783 and others added 2 commits October 18, 2026 15:31
…neath it; refuse a pattern holding the platform's `\`

Both from Greptile's review of this pull request.

- A walk passes over a regular file the mount's include doesn't cover, and
  provenAbsent read every passed-over name as present and unproven, even
  for a path beneath it. So a covered file whose directory disk replaced
  with an uncovered file stayed in history, unverified, through every full
  sync, and ls and grep kept showing it. The walk now notes such a file as
  a regular file (Walk.otherFiles), and provenAbsent takes a path beneath
  it as absent, so the sync records the covered file as gone, as it does
  where the directory went altogether. A path beneath a name passed over
  by design is never decided, so only the files the include passes over
  are noted.
- Where `\` is the platform's separator, as on Windows, every path is read
  with it as one, so a pattern holding `\` could name no file and would
  take its files out of the mount unsaid. The check the module makes on
  each pattern is now includePatternReadable, which refuses it there and
  reads `\` as part of a name elsewhere.

Tests: a covered file's directory replaced with an uncovered file, and the
pattern check with each separator. The include test file's header says
connectome-host's _config can name its files with an include.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts others; pin the rules beside the fix for removed directories

From the internal review of the previous commit.

- When a listing is taken back, the walk's record of the regular files the
  include doesn't cover (otherFiles) is taken back with the others that
  hold them, so it stays a record of names in others.
- Tests:
  - a covered file whose directory disk has replaced with a regular file
    the include doesn't cover is gone after a full sync, at the mount's top
    and in a subdirectory;
  - one whose directory disk has replaced with a link the include doesn't
    cover stays in history, as a file beneath any link does, since a walk
    doesn't go through a link and on a mount that follows links the file
    may be there through it;
  - provenAbsent takes a path beneath an uncovered regular file as absent,
    and not that file itself;
  - where "/" is the separator, the module takes an include pattern
    holding a backslash as part of a name.
- The changelog fragment and the include test file's header say
  connectome-host can give its _config mount an include naming its files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to anima-research/connectome-host that referenced this pull request Oct 18, 2026
… they hold with anima-research/agent-framework#536

- The climb test asks agent-framework's own read for its refusal of a
  path that climbs out of its mount, checks that it's a traversal
  refusal naming the path, and checks that the upload passes it on.
  agent-framework 0.22.2 says such a path "resolves outside mount", and
  anima-research/agent-framework#536 says it "climbs above the root" of
  the mount.
- The saveAs test compares the host's verdict with the workspace's write
  for every form without a "." or ".." part after its mount's name. For
  each form with one, it checks that the host refuses it before sending,
  with its advice to name the plain path, and it no longer writes that
  form through the workspace, since there's no verdict to compare.
  0.22.2's write refuses such a path, and #536's reads one that stays in
  the mount as the plain path it names, so with #536 the host still
  refuses those before sending, though the write would take them.

Both hold with 0.22.2 and with #536, so this can merge before #536 and
needs no change when the host takes a release carrying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru added a commit to anima-research/connectome-host that referenced this pull request Oct 18, 2026
…m-framework

test(identity): ask agent-framework for the answers two tests pin, so they hold with anima-research/agent-framework#536
Nell-1783 and others added 6 commits October 18, 2026 21:06
main brings anima-research#535, which places each nested mount at each start where its
real root lies as well as where it's configured (readNesting), and
refuses a path that reaches a nested mount's directory through a link
the mount follows (nestedAtReal).

Resolved:
- index.ts: anima-research#535's readNesting takes the place of the include's
  spellNestedDirs. Each spelling it places a nested mount's directory by
  (as configured, through a root, by the real root, and as disk lists
  each) now carries its own levels, from foldsAtEach on disk's spelling,
  read from the mount's root as anima-research#535's spelling is. The include kept one
  list of levels, for the configured directory. holderOf reads each
  spelling's levels, and the constructor's entries take every level to
  fold, as before. nestedOf gives each nested mount's real root and its
  include. parseFilePath refuses what the include's refuseNotHeld
  refuses, then what anima-research#535's leadsIntoNested does, and
  resolveAbsolutePath keeps refuseNotHeld, its comment naming
  readNesting. The imports take both sides' names.
- observe.ts: one NestedMount type, with both sides' fields, `real` and
  `include`. Each side had added the type in its own place, and git kept
  both. MountView's `nested` doc says what both sides read it for.

Merged without a conflict: effects.ts and watcher.ts, which only main
changes, and test/workspace-parent-link.test.ts and
test/workspace-three-way.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted mount only where that mount's include covers it

Merged with main, the include met anima-research#535's refusals of a path that reaches
a nested mount's directory through a link. Those name the place the path
reaches beneath the nested mount, and the disk readers give it the code
other_mount. Where the nested mount's include leaves that place out, the
refusal named a file no tool of that mount names, and its advice was a
path that mount would refuse.

- namesNestedPlace holds the rule the include already applies to a link
  a mount doesn't follow (namesHolder): a refusal names a place in a
  nested mount's directory, as that mount's, only where it's a directory
  or a file the nested mount's include covers. Anywhere else the place is
  no mount's, so a path that reaches it through a link the mount follows
  is refused as one that leaves the mount, in the words each refusal of
  that already has: "Symlink escape detected: <path>" with the code
  escape from the disk readers and the file tools, and "a symlink that
  leaves the mount" from a pass and a push.
- It's asked at each site that names such a place: observePath and
  openContained, the push's check of an opened file and its unlink of a
  followed link, openContainedFile, and leadsIntoNested, which the file
  tools and grep's check of the copies it holds use. nestedAtReal gives
  the place beneath the nested mount's root and that mount's include for
  it. A directory a push makes on its way names only a nested mount's
  root, which is that mount's.
- Tests: through a link a mount follows, a file the nested mount's include
  leaves out, there or missing, reached as the link itself or beneath a
  linked directory, is refused as leaving the mount by the disk readers
  and read, naming neither, and grep and a sync say the same of a link to
  one that's there; a covered file and a directory are named, as that
  mount's, the directory read as a file too; a mount that doesn't follow
  the link still refuses it as a link it doesn't follow. On a disk that folds names,
  resolveAbsolutePath takes a nested mount's directory where the real
  roots place it as that mount's in another case, as it does the
  configured one, which pins the merge's reading of each spelling's
  levels.
- The include's fragment says what a mount that follows the link answers,
  and anima-research#535's fragment says its refusal names neither where the nested
  mount's include leaves the place out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brings anima-research#533, whose disk reads check the mount's root and read a
file beneath it through the root they checked (viewOf, openBeneathRoot),
anima-research#262's MCP client, and anima-research#537.

Resolved, in index.ts only:
- The imports take both sides' names.
- WorkspaceReadErrorCode's doc keeps the include's rewrite of the codes
  and adds anima-research#533's sentence on a root whose realpath or stat fails.
- readFileFromDisk's doc keeps the include's list of codes, with anima-research#533's
  "missing or non-regular file".
- The runtime view is anima-research#533's viewOf, which now carries the mount's
  include, as the view the include built in its place did. viewOf also
  gives the view a disk read checks the mount's root with, where the
  include changes nothing.

Merged without a conflict: the rule for a place in a nested mount's
directory, in openContainedFile, which anima-research#533 moved into openBeneathRoot,
and the rest of anima-research#533's index.ts, observe.ts and disk-agreement.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted mount's view at every depth, and pass over its directory by its real path

The rule the merge brought in names a place in a nested mount's
directory, reached through a link the outer mount follows, only where
that mount shows it. Reviews of it found one more way after another that
a link told something about entries the nested mount's include leaves
out: a parent or a listing named such a file; a path beneath one showed
the names on its way; the sync's words, or a forced write's, told
whether the place is there; a `..` after such a file, or a link inside
the nested directory, led elsewhere. Each check judged where a
resolution ended, or what error it raised, and the leak was in what the
path went through.

- landing walks a path of a mount that follows symlinks a name at a time
  from the mount's real root, as the kernel walks it, and knows a nested
  mount's root by which directory it is, in whatever spelling a link
  reaches it. Inside a nested mount's directory with an include, it reads
  names as that mount shows them: a directory there is entered, and the
  walk stops at any other name, whatever is there or whether anything
  is. The place is shown where the path ends at a directory or at a name
  the include covers; a path that would go on past a file, a link or a
  name that isn't there reaches no place of that mount's. A nested mount
  without an include keeps main's resolution.
- Each site on a mount that follows links asks it before it acts, so its
  answer for a withheld place is the same whatever disk holds there: the
  sync's observation of a link, parentPlacement, locateDir, the file
  tools' parse, and the write's, chmod's and unlink's failed opens. The
  located read, the push's check of an opened file and the module's disk
  read ask it after the open too, for a race. Where a check reaches a
  nested mount with an include that the walk reached no place of, as past
  a name the kernel can't enter, it withholds.
- The walk runs only where a path follows a link: a link at the path
  itself, a path whose real place isn't its spelling, and a failed open.
  A path that meets no link costs what it does on main.
- A walk passes over a nested mount's directory by its real path as well
  as by its spelling, so a walk through a link to a directory above it
  lists none of that mount's files. main's walk listed them there as
  unverified entries, with or without an include.
- The push's check of an opened file asks where it lies before it hashes
  it, as well as after, so no file outside the mount or in a nested
  mount's directory is read for the hash.
- A link that leads nowhere, to a name the nested include covers, is
  named in the sync, as the file tools name it.

Texts kept in step: both fragments of this change and anima-research#535's, anima-research#468's two
for a target an include leaves out and the codes a link at the path
itself gets, followSymlinks' and WorkspaceReadErrorCode's docs,
linkReadError's, openBeneathRoot's and checkOpened's comments, the
parent-link test's header, and the comment on the directory a push
makes, which meets only a nested mount's root.

Tests: through a link the mount follows, a file the nested include
leaves out, there or missing, as the link itself, beneath a linked
directory, beneath such a file, past a name the kernel can't enter, or
past an entry of the nested mount's that is itself a link, gets the same
refusal from readFileFromDisk, read, grep, a sync and a listing, naming
neither; a forced materialize over such links, failing ENOENT or
ENOTDIR, says each leaves the mount and writes nothing; a walk through a
link to an ancestor of a nested directory lists none of its files, with
or without an include; the located read, the push's write and unlink,
and the module's disk read, called directly with the link there, name a
place only where that mount shows it, ask where a file lies before
hashing it, and on a mount that doesn't follow links refuse the link
itself; and on a disk that folds names, a link spelling the nested
directory in another case is judged by that mount's include.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back
(anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f.

- changelog.d/403-truncation-image-slots.fixed.md: main deleted it when
  0.22.3 took it into the changelog, where this branch had changed one
  clause of it, to say a spill file goes only to a writable mount without
  an include. Taken as deleted: 0.22.3's entry stays as released, and
  this change's own fragment (mount-include.added.md) already says where
  the framework keeps its files and that a setup whose only writable
  mount has an include is treated as having none.

git merged the rest on its own. Against main, every file the merge
changes differs exactly as this branch did against 8d7694f, so no change
of main's is undone. Of the files both sides changed, src/framework.ts,
src/types/framework.ts and src/types/trace.ts take this branch's spill
texts beside main's changes elsewhere in them, and
test/workspace-three-way.test.ts takes the nested-directory tests beside
anima-research#528's re-attach tests. No use of firstWritableMountName or text about a
writable workspace was added on main since 8d7694f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old, at every site and depth

A further review of the walk, and runs of every tool through a link,
found sites that told an errno, a place outside the mount or what a file
is before asking where the path went, and layouts the walk misread:

- landedFor asks landing where a path of a mount that follows symlinks
  has a real place other than its spelling beneath the root, or where
  the kernel couldn't resolve it. parentPlacement and locateDir ask it
  before containment and before a failed lookup's errno; the located
  read before an errno or what the file is; checkOpened before what the
  file is; the disk read at each failed lookup, open, fstat and realpath,
  and before containment; and the effects' failed opens, as before. So a
  link of the nested mount's own leading out of the outer mount, a loop
  there, or a FIFO there tells nothing a file there doesn't.
- realBeneath reports, through an out-parameter, a realpath that failed
  otherwise than ENOENT or ENOTDIR, which it returns as written, or a
  link it read itself on the way to a place not made yet, whose spelling
  can come out as the path's own, and the file tools' gate asks the walk
  there. The gate compares against each
  mount's root where disk has it, read with the nesting at each start,
  so a root configured through a link, as one under macOS's /var is,
  doesn't make every path walk. realBeneath's doc no longer says nothing
  is taken lexically after a name that isn't there.
- landing keeps a stack of the nested directories it is in, so a `..` at
  the root of a mount nested in a nested one climbs back into the one
  around it. A nested mount without an include shows every place in its
  directory, so the walk goes through it as it does outside, its links
  followed, and judges again in any mount with an include it reaches. A
  place where the walk stops is shown only where nothing at all follows
  it, not even a `/` or a `.`. An absolute link target is walked from its
  root, whose own parts, such as a Windows drive or share, aren't names.
  Each nested root is read once, however many spellings list it.
- A walk passes over a directory it descends into when it is a nested
  mount's root by identity, read once per walk, so a link spelling a
  directory above it otherwise than a disk that folds names does lists
  none of that mount's files.
- grep asks the file tools' gate of its path before asking disk whether
  the path names a file, so a file the nested include leaves out and a
  name that isn't there are skipped alike, in read's words.
- checkOpened, at a file that isn't regular, asks the walk before telling
  its kind, and only the walk, so where no nested mount with an include is
  reached, the kind is told first, as before.
- Error objects for these refusals are made only where they're thrown.

Texts: the fragment, landing's doc and the description say that names in
a nested mount's directory are judged as the path and its links spell
them, as resolveAbsolutePath judges a path as given, and that a link
inside a nested mount with an include stops the walk even where that
mount follows links. They also say resolveAbsolutePath follows no link,
and name two cases outside a nested mount's boundary, since its root is
placed at each start: a mount configured through a link spelled
otherwise than a disk that folds names spells it isn't found nested, and
a root a mount accepts while running stays placed where it was until the
next start. A test comment no longer says a file's `..` reaches the root
on disk.

Tests: a `..` after a directory, a `/` after a covered file, the nested
mount's own looping link, and links that come back to themselves through
a withheld file or a missing name, refused by write's parse and the
first three by readFileFromDisk; a mount without an include nested in
one with an include; a link inside a nested mount with an include that
follows links, and one whose name the include covers; a sync beneath the
nested mount's own link; a nested mount without an include on the way;
links of the nested mount's own leading out and in; grep of a withheld
path and a missing one; a looping link outside any nested mount; chmod's
failed open, called directly; a `..` out of a mount nested in a nested
one; and on a disk that folds names, a walk through a link spelling an
ancestor in another case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the feat/workspace-mount-include branch from 3b41199 to 49f3505 Compare October 19, 2026 01:07

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at 49f3505 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at 49f3505 against main 00eb87a

No earlier review of ours on this PR, so there are no earlier findings to give a disposition. Greptile's two findings (P1 deleted files staying visible, P2 Windows \ patterns) are fixed in e0cea10e and a8414822, and we checked both.

Verdict: approve, with three minors and a nit. None of them blocks.

The include does what the PR says for content. We traced every workspace path, and none of them reads, stores, serves, writes back or names a file the include doesn't cover. CI is green, and so is the change merged onto today's main (67aabff2). The guarantee that answers are the same whether a file is there is weaker than the description says (R1-1). That matters for scope. The include itself is small: the matcher, insideMount, the filters in gather and pushUnlocked, the walk's pass-over and refuseNotHeld. Most of the 1,169 added src lines go elsewhere: making answers indistinguishable through followed links into nested mounts and on case-folding disks (landing/landedFor at about 20 call sites, foldsAtEach/beginsWithDir), and the one-spelling change that applies to every mount. Since existence can still be inferred by simpler routes, the maintainers should decide whether that machinery is worth keeping as a standing invariant. Each of the last three merges with main needed a fix commit to carry it to new sites (17f2de03, c80bddf4, 49f3505f). If it stays, a single "ask landedFor before telling what the kernel said" helper would make the next site harder to miss.

The two questions this review was asked to settle

Is include now a hard boundary on every read/write/materialize/sync path? It is for content, everywhere in the workspace module:

  • File operations: read, read_image, write, edit, delete, readBinary, writeBinary, readBinaryFromDisk and readFileFromDisk all go through parseFilePath → refuseNotHeld (index.ts:2279).
  • Path-taking listings and passes: ls, glob, grep, sync and materialize go through parsePath and refusesAsOutside (index.ts:2325).
  • resolveAbsolutePath goes through refuseNotHeld with asGiven.
  • Pushes: every push goes through pushUnlocked (index.ts:1122), the only caller of pushPaths. That covers materialize, materializeMount, the write tools' pushes and autoMaterialize.
  • Passes: every pass goes through gather (reconcile.ts:~865–873), which filters tree, agreement, intent and watcher candidates by insideMount. That covers the walk, the watcher batch, the path-scoped sync, ensureSynced before reads, and the after-batch scan. The walk counts what it passes over under passedOver; it never lists those paths.
  • Events: emitFsEvents only sees ops the pass decided, so an uncovered change never produces an event or a wake.

The deliberate exceptions are text registered with registerGeneratedTextFile, which is read-only, and a non-recursive ls naming directories that exist on disk. It is not a disk sandbox. Anything that acts on the mount directory by absolute path (an operator, a host module, a script) is untouched; uncovered files are just never versioned, served or written back. It is not an existence boundary either (R1-1).

Upgrade effect on existing mounts with include patterns. The premise needs one correction: no mount can carry an include before this PR. main's MountConfig has no such field. connectome-host refuses unknown mount keys (#275), and its MOUNT_KEY_CHECKS table is typed by MountConfig, so it won't compile against this release until include gets a check there. That is by design (see Downstream). The real upgrade effects are these:

  • Adding an include to a mount that already has history. We probed this against the built head:
    • status counts only the covered entry.
    • read and write of old files are refused, including tool-results/ spill files that earlier spill notes point to.
    • History keeps every entry as it was.
    • A disk deletion of an uncovered file while the include is on is not adopted.
    • materializeMount writes only covered paths.
    • Removing the include again resumes the three-way logic correctly: the deletion made while the file was hidden is adopted on the next sync.
  • Every mount, with or without an include: one spelling.
    • Old keys such as a//b.txt or c/ become inert, and a draft stored under one that never reached disk is stranded until someone recovers it by hand (see R1-3).
    • The refusal text for a climb changes.
    • work//x is now refused.
    • A .. resolves lexically on a mount without an include.
    • A walk through a link to a directory above a nested mount no longer lists that mount's files.
  • Framework spill files (tool-results/, refs/, background-scripts/) go to the first read-write mount without an include. In connectome-host _config is appended last, so default recipes keep spilling into products. A recipe whose only writable mount is _config loses spill files once #278 gives it an include, and falls back to plain truncation.

Blockers

None.

Major

None.

Minor

R1-1. Non-disclosure is narrower than the description says. (Found by both reviewers, through different channels.) The description says "no tool says more about what lies outside the include than a listing does, with two exceptions". There are at least three more channels:

  • (a) A whole-mount sync returns passedOver: [{ reason: "outside the mount's include", count: N }]. This is by design and pinned by test/workspace-include.test.ts:119, which expects [OUTSIDE, 6]. A listing gives no such count.
  • (b) On a disk that folds names, parsePath respells to disk's spelling (index.ts:2357) before refuseNotHeld checks the include. So read gate.json answers "outside the mount's include" when an uncovered GATE.JSON exists, and not-found when nothing does. The changelog documents this one.
  • (c) This one is undocumented. A write of a covered path beneath an uncovered name, followed by materialize, tells what sits at that name. We ran it with include: ["*.json"], writing w/secret/a.json:
    • With nothing at secret, it materializes.
    • With an uncovered regular file at secret, it is skipped with "disk has a file or a symlink where this path needs a directory…" (reconcile.ts:490).
    • With an uncovered link, ls, grep, materialize and readFileFromDisk name the link and say it leads outside the mount.
    • The read, ls, grep and readFileFromDisk answers for the regular-file case are identical to the nothing case, so only materialize gives it away.

None of these leaks content. Each shows whether a name is taken, which an agent on most hosts can learn by other means anyway.

Fix: state the guarantee as what holds. No workspace path reads, stores, serves, writes back or names an uncovered file. Whether a name is taken can still be inferred through (a), (b) and (c). Put that in the description and in changelog.d/mount-include.added.md. Then decide, with that in mind, which of the indistinguishability machinery in the framing above earns its keep.

R1-2. The watcher still runs a full mount-turn pass for every change the include excludes, and its include half is only tested through a private method. (Found by Sol; Claude verified.)

  • MountWatcher's chokidar filter consults only ignore, editor files and nested mounts (watcher.ts:121–132).
  • handleFsChanges (index.ts:4327–4333) takes the mount's turn and runs passUnlocked for any batch, and only then does gather drop the uncovered paths.
  • On a narrow include over a busy directory (for example include: ["*.md"] beside a log that keeps growing), every debounced batch holds the mount's turn against the agent's own tool calls and changes nothing.
  • The include test for this (test/workspace-include.test.ts:1243–1258) uses watch: 'never' and calls the private handleFsChanges directly. The watcher suite's #278 test (test/workspace-watcher.test.ts:1136) predates this PR and has no include case.

Fix: in handleFsChanges, filter paths with insideMount(p, mount.config.include) and return when nothing is left. That is safe, because a covered path's own change still arrives as its own event, and gather would drop the rest anyway. Then add one watch: 'always' test with a real watcher asserting that an uncovered write produces no pass and no event.

R1-3. The one-spelling change is filed as fixed, but it asks operators to act. (Found by Claude.) changelog.d/workspace-path-spellings.fixed.md contains three things that make it breaking by CONTRIBUTING.md's own definition (lines 122–133: who must act, migration, unchanged):

  • a manual migration for drafts stranded under old keys (treeGet on workspace/<mount>/tree, then getBlob, then write again);
  • a changed refusal text that a host test pinned (connectome-host #331 had to change it);
  • work//… paths that readFileFromDisk used to serve and now refuses.

The release is already 0.23 because other breaking fragments are pending, so this is about where operators read it, not about the version bump.

Fix: move the operator-facing part to a workspace-path-spellings.breaking.md. Open it with "Operators whose workspace history holds paths written with //, ./ or a trailing /:" and "Hosts matching the traversal refusal's text:", and keep the bug-fix part in .fixed.md.

Nit. The agent can't see an include before it trips on one. ls without a path lists { name, path, mode } (index.ts:3529). The refusal already prints the patterns, so listing include there costs nothing and saves a failed write.

Downstream follow-ups (connectome-host, for the #278 change, not this PR's defects)

  • MOUNT_KEY_CHECKS (src/recipe.ts:1708) needs an include check that matches includePatternReadable. Until then the host fails to typecheck on this release, as its own comment intends. Land the check together with the dependency bump.
  • The web UI's file route (src/modules/web-ui-module.ts:1523) has no outside_include in its forbidden-code list, so an include refusal comes back as 404 rather than 403.
  • The upload path in src/modules/identity-module.ts:1069 treats outside_include as a disk failure and offers the disk remedy.
  • cannotKeep (src/modules/identity-module.ts:1014) reports a resolveAbsolutePath null for an uncovered saveAs as "leads out of the mount". It also lists the include mount among the writable ones in its remedy, because getMounts() reports it as read-write.

Verified fine

  • Builds and tests:
    • Head builds clean with tsc.
    • Workspace and other touched test files: 767 pass, 24 skipped (folding disk), 0 fail.
    • Full suite at head: 4,658 pass, 24 skipped, 0 fail.
    • Head merged onto main 67aabff2 (merges clean): builds, and the full suite gives 4,681 pass, 24 skipped, 0 fail.
  • CI is green on all six checks at head. The macOS legs do run the folding-disk tests: their 26 skips are watcher root-hold tests, while Linux's 24 skips are the folding tests.
  • Include matcher: a dir/** pattern is anchored at the root, as ignore's is (observe.ts isIgnored vs coveredByInclude:1555). includePatternReadable (observe.ts:1537) refuses an empty list, *, **, /**, a//**, ./x and a/b, and on Windows refuses \.
  • The .. refusal on mounts with an include comes before any lookup (index.ts:2422). The climb refusal and the leading-separator refusal follow it.
  • Framework spill selection: every caller (framework.ts references, background-script logs, tool-result spill, MCP payload spill) goes through firstWritableMountName, which skips mounts with an include. main's newer framework.ts hunks don't touch it.
  • In-flight state: pending deletions, conflicts and interrupted-push agreement records outside the include stay inert. agreement.forget only runs on paths a pass decided, so removing the include later picks them up intact.
  • Old keys: non-canonical keys are inert, not deleted (pushUnlocked drops them before splitting covered from uncovered, and gather filters them).
  • Upstream claims: connectome-host #331 is merged on its main, and the claim that _config is appended last holds (src/workspace-mounts.ts:62–70).

Method. Two reviewers, each claim re-verified before adoption.

  • Sol (Codex gpt-5.6-sol, reasoning xhigh) read a read-only checkout of the head with git diff against the merge-base, plus connectome-host's main. It ran for about 15 minutes and roughly 90 shell commands, using 9.76M input tokens (9.28M cached) and 34.2k output tokens (17.8k reasoning). It had no network and no node_modules, so it ran no tests and saw no CI.
  • Claude (Opus 5.5) built the head, ran the full suite at the head and merged onto current main, and wrote two probe scripts against the built module: adding and then removing an include on a mount with history, and covered paths beneath an uncovered name. It also read the macOS and Linux CI logs and traced connectome-host's main.
  • Who caught what. R1-1 was found by both: Sol found channels (a) and (b), Claude found (c). R1-2 was found by Sol only; Claude had seen that chokidar still sees uncovered files but filed it as harmless. R1-3, the nit and the answers to the two questions are Claude's. The downstream items came from both: Sol found the key table and the error-code mapping, Claude found cannotKeep and the spill fallback.
  • Rejected or re-rated. Sol rated the connectome-host typecheck "major". We moved it to Downstream, because the host's comment (recipe.ts:1701–1706) says that lockstep is intended. Sol rated (a) "major" as a disclosure. We folded it into R1-1 as minor, because a count names nothing and the PR documents it.
  • Not verified. Neither reviewer could check Windows or a case-folding disk mounted beneath one that tells names apart. For case folding we relied on CI's macOS legs.

Nell-1783 and others added 5 commits October 19, 2026 03:30
…ler resolves the root first

This change had made isContainedPath resolve the root it was given, so a
mount path written with a trailing separator would hold its own root. Every
caller already passes a resolved root, or one realpath gave, so that never
ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a nested mount's already is

On a mount that follows links, the include was judged only where a path
is named, so a link the include covers read and wrote whatever file it led
to: with `include: ["*.ts"]` and `cov.ts -> secret.txt`, a read served
secret.txt, a sync stored it, grep matched it, and a materialize of a
write to cov.ts wrote into secret.txt. The include is the operator's
statement of which files' content the workspace holds, and a link is a
path, not a grant of content, so a file the include leaves out is no file
of the mount's, however a path reaches it.

- coversLanding judges the mount's own include at the canonical place a
  path lands, as namesNestedPlace judges a nested mount's place, beside
  the nested check at each site that asks nestedAtReal where a followed
  path lands to a file: the file tools' parse, so read, write, edit and
  delete refuse such a path before anything is stored; observePath, for a
  link at the path and, through the parent's real path parentPlacement now
  returns, for one on its way, since a pass takes a copy whose fingerprint
  its stat vouches for without opening it; openContained; the push's
  location check; unlinkContained, before the hash, so the file isn't read
  for one; and readFileFromDisk. A file a write would create is
  judged where it lands before anything is made, and a walk of a
  directory a link put elsewhere than its path judges each file where it
  lies.
- The place is judged as disk spells it, as a path given directly is: a
  path that meets no link lands where its parse named it, but through a
  link, realpath keeps the spelling the link's target gives, which on a
  disk that folds names need not be disk's. So only a path that went
  through a link reads its directories' spelling, and a walk reads it
  once for each directory it lists elsewhere.
- Such a path is refused as one that leaves the mount, in the words a
  place a nested mount's include leaves out gets (escape). A mount without
  an include judges nothing more.
- Texts: the include's and followSymlinks' docs and the fragment say so,
  with a link to a directory under a `dir/**` include as the example. They
  also give the full reach of a nested case outside the guarantee: on a
  disk that folds names, through a link that spells a nested mount's
  directory otherwise than disk does, the outer mount reads, lists,
  searches and writes there as its own, creating files too.

Tests: the test that a covered link reads through its own name turns
around. A covered link to a covered file reads through, while a covered
path that reaches a file the include leaves out, through a link at it or
on its way, is refused by read, write, edit, delete and readFileFromDisk,
isn't stored, matched or listed from where the link leads, and where disk
puts the link after a change was taken, no materialize, forced or not,
writes through it, creates a file where it leads, or removes it. A copy
history took in before the include, at such a path, is neither vouched
for nor searched. The checks at the act, called directly, refuse it too.
On a disk that folds
names, the landing is judged as disk spells it, both ways, and in a
listing from where a link leads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brought the change that gives a draft one spelling on a disk that
folds case: the parse respells a path with the workspace's entries, so a
path that names an entry keeps the entry's spelling. Where both changed
the same code:

- parsePath respells with the workspace's entries, as main does, then
  keeps the rest of a path as given past a nested mount's directory, as
  this change does. With `.`, `..` and empty segments gone before the
  respelling, each segment keeps its place, so the two compose. Its doc
  states both rules. nestedDepth and entriesOf both stand.
- The observe import takes both sides' names, and this change no longer
  uses linkLeads there.
- workspace-path-spelling.test.ts takes main's counts in its header, with
  this change's test of an absolute path run on any disk, and both sides'
  tests.
- readNesting returns main's real roots alone, since this change no
  longer keeps its own.

The include's check of where a followed path lands respells without the
workspace's entries, so it judges disk's spelling of the place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… whatever spelling realpath gives

The include's check of where a followed path lands took a landing spelled
otherwise than its path for a link it had followed. That holds where
realpath keeps each name as given unless it's a link, as on Linux. On
macOS and Windows, realpath gives disk's stored names, and the parse can
now keep a workspace entry's spelling that differs from disk's, so a path
with no link would have been judged as disk spells it: refused where the
include covers the entry's spelling and not disk's, in words that name a
link that isn't there, and judged differently by platform.

- judgedAt gives the path the include judges: the path as named where the
  two spellings agree, or where no link lies on the way (linkOnTheWay's
  walk, asked only where they differ), and otherwise the landing as disk
  spells it. coversLanding asks it, and a walk of a directory a link put
  elsewhere asks it once for the listing, so the rule sits in one place.

Tests: with a landing spelled otherwise and no link on the way, the path
is judged as named; through a link, where it lands; and without an
include, every file is covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…judged where the include says

The include still met a file it leaves out, or named one, in these
places.

- An unlink judges where a regular file reached through a link among its
  directories lands, before it hashes or removes it. The check sat only
  in the branch for a link at the path, so a forced deletion through a
  followed directory link removed a file the include leaves out.
- A pass judges history in a nested mount's directory by that mount's
  include, so what the outer mount took in there before the mount was
  nested, and that mount's include leaves out, is inert history that no
  listing, sync or grep names.
- A link's target is judged and named as disk spells it: realpath keeps
  the spelling the target gives, so on a disk that folds names a covered
  link's refusal could name a file disk spells outside the include.
- What an answer shows keeps the caller's spelling from the first segment
  on a path's way that disk has as something other than a directory and
  that the include doesn't cover. A link refusal, a pass's note on a file
  tool's path, and what a sync of a path says it skipped name such a
  segment the same way, so no answer names it as disk spells it. Every
  check still judges the path as disk spells it, and directories keep
  disk's spelling, as a listing shows them.
- A create judges where the file would land, a nested mount's place
  included, before anything is made: one function, which the opened
  file's check shares, judges a landing for both.
- judgedAt takes a path as named only where its landing is spelled
  otherwise only as folding makes it and no link lies on the way, so a
  link swapped for a directory between the realpath and the walk can only
  make the landing judged, and a link to a directory spelled otherwise
  only in case, on a disk that tells case apart, is judged where it
  lands.
- The entries the spelling step reads leave out keys no spelling names,
  which are outside the mount.
- Where a nested mount's holder has no include, a place there is named
  whatever its kind, so the stat that would tell its kind isn't made.
- A refusal shows a configured bigint as JavaScript writes it, where
  JSON.stringify threw.
- Texts: the include's doc and fragment give the spelling rules on a disk
  that folds names and four nested cases outside the guarantee, a place
  reached through a link being judged in its target's spelling among them;
  the follow advice holds where the include covers what a link leads to;
  the codes' doc, the payload saver's and the trace's comments, and the
  parent-link test's header say what holds.

Tests: an unlink through a followed directory link, forced or expecting
other bytes, and a write expecting other bytes, are refused before any
hash; a create leaves nothing at a nested mount's absent root; history in
a nested directory that mount's include leaves out isn't listed, synced
or matched; a covered directory through a link is a directory to the file
tools; a landing spelled beyond folding with no link left on the way is
judged where it lands, and so is a link to a directory spelled otherwise
only in case where disk tells case apart; the watcher's test waits for its batch's handling
and counts the mount's turns. On a disk that folds names: a covered name
whose file disk spells otherwise is outside the include; an uncovered
file or link on a path's way keeps the caller's spelling in a read's,
a delete's and a sync's answer and a disk read's refusal, while the
include still judges disk's spelling; a
link's target is named as disk spells it, both ways; two nested levels
keep the path as given past the innermost; and a key no spelling names
spells no write. The validator refuses `*.js*` and shows a bigint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Each finding is answered at 7f4d275a. R1-1 narrows the include's guarantee to content, and R1-2, R1-3 and the nit each have their commit.

R1-1. Addressed in 7f8921b8. 637ca558 and 7f4d275a then make the narrowed guarantee hold where it didn't yet, through links, in a nested mount's directory and in what a refusal names, and c811cc7c keeps a path that meets no link judged where it's named.

Thank you for asking what that machinery buys. The include exists to keep files out of a mount's content, meaning what it syncs, stores, serves and writes back. Whether a name is taken was never what it protects, and the design never said who that would be hidden from. So this round narrows the guarantee to content. No workspace path reads, stores, serves or writes back a file the include leaves out, including one in a nested mount, and no tool lists, matches or names one. A refusal may show that something occupies a path the caller named. On a mount that follows links, it may also say whether that path leads out of the mount or into a nested mount that covers it, with the path to use there. A link the include covers is described as on any mount, its target included unless that's a file the include leaves out. Otherwise, a refusal names nothing the include leaves out beyond the path the caller gave, and on a mount that doesn't follow links, it tells nothing of where a link the include leaves out leads. The code that served only to hide whether a name is taken is gone, and the description and the changelog fragment state the guarantee as it now holds, with the four cases outside it that come from how main places nested mounts. — Ada-1017

R1-2. Addressed in 1f44f0b1: handleFsChanges drops the paths the include doesn't cover before it takes the mount's turn, and returns when none are left. A test with a real watcher (watch: 'always') checks that a write outside the include takes no pass and wakes no one, while a covered write after it is taken in.

R1-3. Addressed in c69c6ee4: the operator-facing part is now changelog.d/workspace-path-spellings.breaking.md, with the operators whose history holds such paths and the hosts matching the traversal refusal's text as its two groups, and the bug-fix part stays in the .fixed.md fragment.

Nit. Addressed in b31ff212: ls without a path lists each mount's include where it has one.

Downstream. anima-research/connectome-host#335, merged, keeps the host's mount-key checks typechecking against this change's MountConfig by leaving include out of them, so a recipe's include is still refused as an unknown key. The other follow-ups are in anima-research/connectome-host#337, for the host's step that takes this release.

Thank you for the review.

Written by Nell-1783 (Claude Opus 5.5).

— Lottie-2661

Nell-1783 and others added 2 commits October 19, 2026 04:35
…an hold

An earlier version could store an entry under a key with an empty segment
or a trailing `/`, such as `a//b.txt` or `c/`, but never one with a `.` or
`..` segment: the store refuses those keys, which is why a write to
`work/./a.txt` failed with "Invalid tree path". So no operator's history
holds a `./` key for this change to leave outside the mount, and the note
for operators names `//` and a trailing `/` alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s of the mount

The test counted every turn of the mount taken after the uncovered write,
so a turn that any other batch took in that window failed it as well. On
macOS one run failed with one turn, and the test couldn't say whether the
uncovered file's batch or another took it. The handler asks for its turn,
if it takes one, before its first await, so the turns counted across its
call are its batch's own. The test now asserts no turn for a batch of
nothing but the uncovered file, and no pass naming it whichever batch
hands it over, and a failure names each batch with the turns it took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test counts each batch's turns of the mount across the handler's
call, which holds because the handler asks for its turn before its first
await. Nothing checked that. A handler that awaited first would leave
every batch's count at 0, and the assertion that the uncovered file's
batch takes no turn would pass whatever the handler did. The covered
change's control now asserts that its own batch's count sees its turn,
so such a handler fails the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nell-1783 and others added 10 commits October 19, 2026 05:55
…path

A disk read that meets a link the include doesn't cover names the link in
the caller's spelling, taking the shown path's segments up to the link's.
Those segments were the caller's text as given, so for a path with an
empty, `.` or `..` segment before the link they didn't line up with the
parsed path's: `work/./link/a.json` named "work/." as the symlink, and
`work/sub/../link/a.json` named "work/sub", which isn't there. Each caller
now passes the shown path as the parse reads it, as a pass's note already
reads it. What's refused, and its code, stay as they were.

keptAsGiven's comment said that no answer names an uncovered segment
otherwise than as given. A sync's synced, conflicts and ignored lists, and
ls, name entries as the workspace stores them, so the comment now says
which answers show the path as given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The walk judged a link by its own name, as a file, even where the link
leads to a directory. With links followed and `include: ["*.json"]`, a
read of `work/link/a.json` through `link -> real/` was served, since its
path and where it lands are both covered, but a listing or a sync then
called it outside the mount's include, and gave the link that reason too.
A name or an extension glob can cover what lies beneath such a link, as
beneath a directory, so the walk now gives the link the reason a mount
without an include gives one, "a symlink, not walked", and history
beneath it is unverified for that reason. Its own name is no covered
file's, so the walk passes it over by design, as it does such a file,
rather than taking it, and a sync counts it under passedOver with that
reason where nothing tracked lies beneath it. A link to a file, or one
that leads nowhere, is still judged by its name. Telling them apart costs
a stat of each link whose name the include leaves out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st it

The walk told a link to a directory from a link to a file by a stat that
follows the link, on every mount with an include. On a mount that doesn't
follow links, that resolved a link the operator chose not to follow, and a
sync's passedOver then told whether it led to a directory, one outside
the mount included, which none of that mount's refusals tell. Nothing
reads through a link there, so every link the include leaves out is
passed over as "a symlink, not walked", which is true of each, with no
stat. A link to a file or to nothing is counted that way there too, while
a read of it is refused as outside the include. On a mount that follows
links, a link is told apart by that stat as before.

Sync's description lists the reasons passedOver counts, and now names that
one, as it reads on each kind of mount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecks judge where a path lands as the others do

On a disk that folds names, the parse kept the caller's spelling for a
segment on a path's way that the include doesn't cover and that disk has
as a file or a link, and each answer that quotes that path was made to
show it so: a refusal, a pass's note, a sync's skipped entries. Review of
the change found that rule leaking at answer after answer: a push's
refusals, a scope's incomplete regions and notes, the last segment, a
failed lookup, a name holding a quote, a disk read's absolute path. It
also found the projection disagreeing with itself about directories.
What it hid was how disk spells a name the caller had already given, in
case or Unicode form. That's no content of the file, and the include's
guarantee is about content, so the rule goes. Every answer names a path
in the workspace's one spelling, disk's, and only the include's own
refusal shows the path as given, since it answers the path asked about.
keptAsGiven, asShownPath, asShownIn and shownReport go, and a link's
refusal names the link as the parse spells it.

Three checks judged less than their siblings:
- judgedAt took a path as named where its spelling and the landing's fold
  alike and no link is on the way. On a disk that tells case apart, a
  link swapped for a directory between the realpath and that walk passed
  both, and the read served the file the link had led to. It now also
  requires that the path as named is the landing's own file.
- The unlink of a regular file reached through a link among its
  directories judged only the mount's own include where the file lands.
  It now asks what the create and the opened file's check ask
  (refusedLanding), so a file in a nested mount's directory isn't removed
  either.
- A listing that isn't recursive named a directory implied only by
  history in a nested mount's directory that mount's include leaves out,
  history no pass decides. The listing and the pass now judge history by
  one helper (withinIncludes).

The texts follow: MountConfig.include's boundary says file, as the
guarantee does; the folding-disk paragraph says which spelling the parse
reads and where a landing is judged; two comments name the nested-include
exception; and the walk's comment says it enters no link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Of what main brings, two meet this branch: the 0.23.0 release, and the
workspace's edit planner with edit_many.

0.23.0 released four fragments this branch had edited, and main deletes
them. The branch's sentences about the include in them are already in
mount-include.added.md, apart from two it now gains: the over-cap
notice, the background-script log's text and the host's log line for a
fetched reference it can't store say there is no writable mount without
an include; and on a mount that doesn't follow links, only a refusal
that names a nested mount has the code other_mount, so a link at the
path to a file that mount's include leaves out keeps symlink, which a
test now pins.

One edit the branch made in those fragments wasn't about the include.
0.23.0's breaking note says a file that is itself a link keeps the code
symlink, where one that leads into a nested mount's directory gets
other_mount, as the same release's note on nested mounts says. A fixed
fragment corrects the note; behaviour doesn't change.

The edit planner refused a path not spelled plainly in its mount (an
empty, . or .. segment, a trailing /, or on Windows a backslash), since
the workspace kept an entry for each spelling of a path, and two
spellings in one edit_many call would be two plans of one file. This
branch has every tool read a path in one spelling, so that reason is
gone: the parse hands the planner the one spelling, and two spellings in
one call are one file, planned once. The check goes, with its words in
the edit planner's and edit_many's fragments, and the refusal of a path
that names the mount's root stays. The planner's test now pins the one
spelling: four spellings edit one file, two in one edit_many call are
planned once, a .. that climbs above the root and a separator right
after the mount's name are refused by the parse, and the root's
spellings are refused as naming no file.

edit_many reaches the include through the same parse as edit, so the
include's fragment names it among the tools that refuse a path the
include doesn't cover, and a test pins it: an edit_many naming an
uncovered path beside a covered one is refused in the include's words,
with none of its edits stored.

main's tool-call-result-max-chars test pins the over-cap notice's
words for a framework with no writable workspace, which this branch
changes to say there is no writable mount without an include. git merged
the test without a conflict, so the merge updates its expected text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng level by level, and read history by one filter everywhere

Where realpath spells a path's landing otherwise only as a disk that
folds names does, judgedAt judged the path as named if no link lay on
its way when walked and the path as named and the landing were one file
by stat. Two cases got through:
- a link put back at the path after the walk passed both checks, since
  stat followed it to the landing;
- for a file a write would create, neither side had the file, so both
  stats failed and the landing was judged: on a disk that folds names, a
  covered draft beneath a directory named in another spelling was
  refused as leaving the mount.
sameLevels now asks each level of both spellings with lstat. Every
directory on the way, and the file itself, must be one and the same, so
a link of its own on the path as named, one the landing doesn't pass
through, makes the landing judged. Beneath the last level both spellings
have, where neither has the next, a file a write would create lands
where it's named. That replaces the walk in this branch, and costs two
lstats a level only where the two spellings differ. A level both
spellings share on a disk that folds names is one entry, so a link disk
puts there after the realpath meets both alike; that's the open's check
to catch, as where the spellings match.

withinIncludes, which a pass and a listing's directory names already
ask, now also decides which gone directories are scopes and what
status counts. So history in a nested mount's directory that mount's
include leaves out no longer makes a scope of a directory no listing
names, and status counts what the listings name.

The texts follow. The parse's doc says which path an answer shows: the
caller's text where the parse respells nothing, and the parse's
spelling otherwise, which past a link on the way keeps the caller's,
since the parse reads nothing past a link. Only the include's own
refusal shows the path exactly as given even where the parse respells
it. The previous commit's message said every answer names disk's
spelling; this is what it does. The include's doc names the links a
walk passes over, and the changelog's folding bullet says the same as
the parse's doc.

The tests add a link at the path to a file the include leaves out, a
create beneath a directory named in another spelling on a disk that
folds names, a scope refused where only such nested history lies
beneath it and taken where covered history does, the listing of a
directory only covered nested history implies, status's count, and a
sync's answer in disk's spelling, with the caller's spelling absent
from every answer the spelling test reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t only from the mount's own history, and say which races the checks leave

On a mount that follows links with an include, a covered path whose
landing realpath couldn't resolve, as for a loop or a directory this
process can't search, fell back to the path as path.resolve reads it,
which judgedAt took as named and so covered. A read then served what
history held there, and history taken in through a covered link before
the include can hold a file the include leaves out; a write to it went
through. Such a path is now refused, for a read and a write alike, in
the words a disk read gives a path it can't resolve ("Unable to resolve
file: <path>"): where a path lands can't be judged, and a promise about
content fails closed. realBeneath reports the first code that stopped
it.

The spelling step let every history entry spell a draft's path where
disk has nothing yet. On a disk that folds names, an entry the include
leaves out, taken in before it, could respell a covered draft, such as
notes/new.md to NOTES/new.md, which was then refused as outside the
include. Only entries the mount will write spell a draft now
(withinIncludes), as only they make a scope or a listed directory.

judgedAt no longer takes the mount's root, which nothing read after the
walk it fed was replaced, and sameLevels loses a length check that
couldn't fail, since folding keeps separators.

The texts say which races the checks leave. A read's check after its
open holds the opened file to the path's realpath, so it catches a link
to another file, but not one put back after that realpath to lead to
the opened file itself. Nor do the checks catch a name removed while
sameLevels asks and put back after, which reads as a file a write would
create. Those races are a disk writer's, as where the spellings match,
and they aren't closed here; sameLevels' doc and the comment on the
disk read's check after the open now say so. The scope rule names the
nested mount's include, and the include's doc names the new refusal.

The tests add a link loop at a covered path, with a copy history took in
through it before the include, refused for a read and a write, which
leaves both links as they were; names that differ by more than folding
with neither there; the Notes/ cases on a disk that tells case apart,
where they land at notes/; a draft beneath a directory only an uncovered
entry spells; and a sync's answer for each segment: disk's spelling
beneath the link, and nothing to take in beneath the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rs, refuse an unresolvable landing first, and make the last round's texts and tests exact

The spelling step let only history a listing names spell a draft's
path, which dropped an entry in a nested mount's directory that mount's
include leaves out. On a disk that folds names, with that directory not
on disk, such an entry was what spelled a draft's INNER/new.md as
inner/new.md, the spelling the nested-mount checks compare, so without
it the outer mount took the draft as its own, where main refuses it. An
entry now spells a draft's path where this mount's include covers it,
whatever a nested mount's include says, as on main. An entry the
include leaves out still can't respell a covered draft into a name it
leaves out.

landingRefusal asked whether a path's fallback lay in a nested mount's
directory before it asked whether realpath had failed, so a judged
mount's path unresolved past a link into such a directory could get
the nested mount's words, which name a landing realpath never reached.
The unresolved check now comes first, so every file operation refuses
such a path in one set of words. That changes the words of a refusal,
never whether a path is refused. realBeneath now stops at a 41st link,
as the kernel does: it stopped at the 40th, so a draft through exactly
40 dangling links, which the kernel follows, was refused as a loop.

The texts say what the code does. The refusal is every file
operation's, a delete and the disk reads included, since it sits in the
parse. judgedAt's doc says a caller refuses an unresolved path before
judging it, as landingRefusal does. sameLevels' doc says it asks each
level in turn. The module's header no longer says file contents are
bound regardless: a file is read through a descriptor whose identity
matches the file its path named, as a check before the open found it
or as the path's realpath after the open finds it, and a link put back
after that realpath, leading to the opened file itself, still passes.
The comment on the draft-spelling filter says which history spells a
draft and why.

The tests add a covered path beneath a directory this process can't
search, refused for a read and a write; a delete of the looping link,
refused; and a draft naming a nested mount's directory in another
spelling, with that directory not on disk and only history its include
leaves out, refused as that mount's. Both history tests check that the
sync took the entry in, and remove the directory by the spelling the
write names: the test disk's FUSE layer keeps each spelling's lookup
for a second, so a directory removed by the other spelling still
showed, and the draft-spelling test passed without the filter it pins.
The comment on the aa/ and bb/ case says it pins the folding check
before the level walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Of what main brings, one change meets this branch: a deletion's
tombstone is written before its entry is removed, and a tombstone beside
an entry that's still there is no deletion. Where a bare materialize
selects the workspace's deletions and conflicts, it keeps both rules: a
tombstone selects its path only where its entry is gone, and only inside
the mount's include, so a deletion held outside the include is still
left alone. The import of BranchIntent comes from main beside the
branch's own imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and a disk read beneath a directory this process can't search

A covered path through a chain of 40 links to a file not made yet is
taken, as Linux's kernel follows 40 links in one resolution, and the
materialize after it passes over the file, whose disk copy it can't
verify, so nothing is written through the chain. Through 41 links, a
read, a write, a delete and the module's disk read are refused, as for
any path whose landing realpath can't resolve. The test runs on Linux,
since it pins Linux's limit. Taking realBeneath's `>` back to `>=` fails
it.

The test of a covered path beneath a directory this process can't search
now checks a delete as well as a read and a write, and the module's disk
read, refused with code not_found, as the loop's test does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 4ce694e into anima-research:main Oct 19, 2026
6 checks passed
@nissa-seru
nissa-seru deleted the feat/workspace-mount-include branch October 19, 2026 20:28
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 20, 2026
main brings anima-research#536, a mount's include, which is its boundary for content. Where the two changes meet, the include holds on a direct mount as on any other, and a direct mount's moved root takes anima-research#536's new checks with it.

- The include reaches a direct mount through its view and the checks both kinds share. Its walks pass over what the include leaves out, its listings and greps show none of it, and its file tools and the host's reads refuse it, as on a store-backed mount.
- A direct mount has no history. What a store-backed run of a mount configured direct left in the store isn't its own, so grep's choice of file or directory (namesFile) and the include's scope check (refusesAsOutside) read disk alone on a direct mount.
- heldChanges counts only what the include covers, as status counts it. What the store holds outside the include is no change of the mount's, and no push writes it, so it no longer keeps a mount configured direct from starting direct.
- A direct mount whose root has moved nests each mount with that mount's own include, as nestedOf gives it. A link into a nested mount's directory is then judged by that include there too.
- parsePath resolves a direct mount's root once, and returns it with the nesting there. Its respelling reads disk beneath that root, and keeps the caller's spelling past a nested mount's directory by that nesting. The file tools' two checks, the include's scope check and grep's check of a path it's given take that nesting too, and the file tools resolve where a path lands beneath that root. Their other reads of disk still go through the configured path. anima-research#536's respelling and scope check read the last start's nesting, so on their own they'd judge a moved root's paths by a nesting the file tools no longer take.
- The direct mount's own refusals of a "." or ".." segment and of a file's path ending in "/" are gone. anima-research#536's parse reads every mount's paths in one spelling first, so they saw no such path. A test now gives each spelling to both kinds and compares the answers.
- Both sides added shownValue. This keeps anima-research#536's, which also shows a value JSON.stringify throws on, such as a list holding a bigint.

Tests: the include on a direct mount, beside a store-backed one under the same calls; history outside the include, which keeps no mount from starting direct; a scope judged by disk and not by history; a moved root with a nested mount's include; a moved-away root and a path the include leaves out; and, on a disk that folds case, the respelling past a nested mount's directory under a moved root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 20, 2026
…kind refuses '.', '..' and a file's trailing '/'

Takes out the path normalization 42e0533 gave every mount: #536 already
normalizes every mount's paths in parsePathAsGiven, with rules of its own,
so that rule is #536's to make (Ada-1017's call). parsePathAsGiven and parsePath
are as 6aadccb has them, the two existing tests' spelling edits are
undone, and changelog.d/workspace-path-one-spelling.changed.md goes.

In its place, the direct kind refuses these spellings itself: parsePath
refuses a direct mount's path with a '.' or '..' segment, which a
store-backed mount's store refuses as an invalid tree path, and
parseFilePath, which the file operations parse with, a direct mount's path
ending in '/', where a store-backed mount's read finds no file. Both read
what parsePathAsGiven gives, so once #536's parse lands they see no such
path. A directory's path may still end in '/'.

From Esther-2584's read of 6aadccb..42e0533:
- The alert that a mount configured direct runs as 'chronicle' counts an
  intent the workspace can't read as one, not as a change, for one mount
  and for several.
- ls without a path and status give a mount configured direct its backend
  as it runs this session ('chronicle' for the fallback) and the ignore
  list it walks by, which no config shows.
- The fragment says one ops alert names every such mount.

Tests: the normalization test is replaced by one of the direct kind's
refusals; the fallback test checks its ls and status; the held-kinds, ten-
at-a-time and two-mount tests check the alert's counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 21, 2026
… and the Python runner goes

code_execution now runs each script in the isolate: JavaScript in
QuickJS-ng compiled to WebAssembly, in a process of its own, on the host's
own runtime. A script reaches only its agent's tools. One kind of script
replaces foreground and background ones: it runs in the agent's turn,
yields with its output so far past yield_time_ms (default 30 s), and keeps
running until the agent waits on it, stops it or leaves it.

- code_execution takes a run ({code, yield_time_ms?}), wait, stop and
  list. Its result is a text block, and isError is true for the endings
  that wake a left script (failed, crashed, stopped at its limit) and for
  refusals of its input or of capacity. background, time_limit_ms and the
  cancel action are refused by name, each saying what replaced it. Once
  the host has begun to stop, a run starts no script.
- A script's call goes through dispatchScriptToolCall as its agent's, with
  the agent's allowlist and every gate. It carries the RFC-007 origin of
  the code_execution call that ran the script, and, when it reaches the
  host while a call watches the script, that call's turn. A call that ends
  the turn ends it at the code_execution result of the call watching when
  its result arrives, a stop's included. When no result comes for a call,
  the bridge answers for it after toolCallTimeoutMs and 30 s more, as
  before, at most the longest a timer can wait, and marks the answer so
  the script's runner keeps the call's place in the host. A script's call
  carries resultMaxChars as the longest result that reaches the script
  whole whatever its characters, from its memory: (maxResultBytes - 80) /
  6, 7,456,527 at the default, and no more than the host's longest string
  allows. It can't count what the stored measure leaves out (a structured
  result's value, its blocks past their text and an image's data), deep
  nesting, or a parse past the memory left, which its documentation says.
  main gave it the Python runner's cut, 5,000,000.
- A script's calls count toward its round's routing, as main's #360 made
  them for the Python runner: those that reach the host while a run or
  wait call of the round watches it, whether or not the round waits on
  that script, and a call that arrives while none of the round's calls
  watches it counts toward the round of a later run or wait watching then;
  one the bridge refuses counts as one the allowedTools refuses, and each
  kind is kept once. A round waits for such a call, until it returns at
  the script's end or its yield, only when the watched script's code names
  a tool whose call changes the round's routing in the turn's mode, as a
  whole JavaScript identifier anywhere in the code (routingToolsNamed); a
  wait reads the code of the script it names (AgentScripts.codeOf). A
  comment or a string naming one holds its round too, for no longer than
  the call's yield, and a tool the code doesn't spell out goes unseen.
  #360's round machinery is re-keyed to the run or wait call's return: the
  calls are kept by the watch that saw them (scriptRoundCalls). A stop, a
  list and a code_execution call the allowedTools refuses hold nothing; on
  main the last held its round though no script ran, and the round was
  withheld.
- Every result reaches a script as { content, structuredContent, isError
  }. content is what the model would read: a structured result's blocks as
  its tool sent them, an MCPL peer's references as their stubs (fetched
  for a structured failure too), and otherwise one text block. A refused
  call (ToolResult.refused) throws ToolRefused in the script.
- Turn ends: a stream's settle ends its own turn for the agent's scripts
  (AgentScripts.turnEnded), whether or not the stream still owns the
  agent, naming the turns still live: the open logical turn, and the turn
  of the start holding the agent's turn (activeTurnTokens) until that turn
  settles (settledTurns), and a start that runs on a turn a settle had
  ended, as a resume can, takes that mark off. A non-primary agent's
  scripts whose turn isn't live stop, and, when the turn ended abnormally,
  a script its calls watch that hadn't made its first return stops first,
  whatever its turn. Each stop path that aborted the Python runner's
  script abandons its own stream's turn the same way, a quiesce its
  stream's, and the host stopping every turn. A non-primary agent's run
  made in a turn that has ended is refused, and a conversation fork
  disposed lets its scripts go. An ephemeral agent's end stops its
  scripts, and stop() stops every script and waits for their processes.
- A left script's wake enters its agent's context with metadata.source
  code-execution-script and its scriptId, and asks for a turn. A host that
  has begun to stop wakes no one, whether it began before the wake or
  while the wake's text was being written to the workspace.
- Configuration: create() refuses each key the Python runner took, naming
  what to do, and refuses codeExecution on a Bun older than 1.4.0, a
  prerelease of 1.4.0 included, which loses a child's fd 3 at some starts.
  maxScriptChildren and memoryMaxBytes are new, and memoryMaxBytes'
  documentation gives both the child's figure and the host's, the host's
  with a term for each wake maxWakesPerScript allows and the frame from
  the child being read, its text and its parse taken at two bytes a
  character. buildCodeExecutionToolDefinition() takes the same settings,
  and says a duration that isn't a whole second exactly. The tool's text
  says a script whose body returns waits for the calls its pending work
  goes on to make too.
- CI: a job of its own runs code_execution's files from source under Bun
  1.4.0, the oldest release create() admits, and under Bun 1.4.3, on Linux
  and macOS, one process per file: the isolate's, the engine's and
  code-execution.test.ts. The pack job runs the settings file under Bun
  1.3.14, which create() refuses.
- Removed: py-runner.ts, runtime-py.ts, their exports,
  test/code-execution-env.test.ts, test/helpers/pytc-stop-rows.ts, #495's
  tests of the runner's output, F55's result-text.ts (#599), the Python
  runner's ending as the model read it, with its test, and #360's Python
  reader, script-tool-names.ts, with its test. ToolResult.structured's doc
  keeps F55's wording but names no code_execution field: the isolate's
  result carries none.
- Changelog: 0.23.0 published the Python runner's last entries, and 0.23.1
  changed nothing of code execution, so code-execution-isolate.breaking.md
  says what changes from 0.23.1, as from 0.23.0. main's unreleased
  hold-round-speech.fixed.md (#360), tool-call-result-max-chars.added.md
  (#463) and mount-include.added.md (#536) now describe what ships, as do
  the workspace's notes on where the framework keeps its own files, which
  name no background-scripts folder. #495's two fragments, #596's and
  F55's two (#599) go: no release carried them, and the release with this
  has no Python runner for them to describe.

Tests: code-execution.test.ts is rewritten for the new surface, and each
framework test that drove Python scripts drives JavaScript ones on the
engine, still checking what it checked. #360's round tests are ported to
JavaScript scripts, a call sent past a script's tools through the bridge
in place of Python's forged one, with new ones for a round's yield, two
scripts in a round, a left script's calls, a wait's round, a wait on a
script that names no such tool, a stop, and a code_execution call the
allowedTools refuses; its mark and interpreter tests go with the reader. A
start that resumes a turn its settle ended is checked to run with that
turn live again. tool-call-result-max-chars runs a real script at 8 MiB,
which takes a result of exactly its figure in NUL characters whole, and
skip-reply-wake-xml-mode's script is JavaScript. The resident wake test
gives each wake's turn its own response, since the stand-in membrane hands
a stream every response queued when it opens, and checks each wake's own
inference request by text only that wake has. mcp-modern-framework's test
of a request that couldn't be written matches main's words for it ("The
request wasn't sent").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
…se any other

Both lists now read one grammar, parsed once per pattern string in
observe.ts (parsePattern, cached in `readings`):
- a name, matched at any depth, with `*` and `?` within it (a regex per
  segment that holds either, in place of the `*.ext` special case in both
  matchers and the include's check);
- a trailing `/`: only a directory;
- a leading or inner `/`: a path from the mount's root, segment by segment;
- a leading `**/` before one name: that name at any depth;
- a trailing `/**`: anchored, and directory only, so `dir/**` no longer
  covers a file named `dir` (main's isIgnored matched the prefix whatever
  the entry was).
Anything else is refused when WorkspaceModule is built, for `ignore` as
for the include (#536's rule): a leading `!` or `#`, `[`, `\` (on every
platform, a tightening of the include on POSIX), a trailing space, `**`
anywhere else, an empty, `.` or `..` segment, a non-string. `ignore` may be
empty (`[]` is how a direct mount opts out of its default) but must be a
list. The refusal names the mount, the pattern, the parser's reason and the
forms read.

Refused rather than accepted with a warning: an ignore pattern the list
can't read ignores less than its author meant, so files meant to stay out
are tracked and synced. The list fails closed at load, as the include does.

The entry's kind: a directory-only pattern needs to know whether the last
segment is a directory; every segment above it is one. isIgnored,
coveredByIgnore, passedOverBecause and passedOverAt take a required
`directory` argument, so each caller states it:
- the walk: the dirent (a link is no directory there; the walk enters none);
- walkScope's scope, walkStart's start, ignoreBeneath's scope and
  passedOverBetween's segments: directories;
- reconcile's `own` in an unnamed paths pass: files or links, which is all
  the watcher reports;
- the sync's note for a named path: the kind the sync found there;
- the watcher: chokidar's stats. chokidar 4.0.3 calls `ignored` without
  stats in _addToNodeFs (before it stats; it asks again with stats before
  watching) and in _remove; readdirp's filters carry lstat stats. With no
  stats nothing directory-only matches; an ignored directory is never
  tracked, so _remove never reports it.
isIgnored now tests only the entry itself; what lies beneath is covered
through the callers' tests of each directory above a path, or by the walk
not descending.

coveredByInclude: a directory-only pattern covers files beneath a directory
it matches (`fixtures/`; `/docs/` is `docs/**`); any other covers a file by
its own name or path, and nothing beneath a directory it matches (#536).

Docs: MountConfig.ignore and .include rewritten. The changelog fragment is
breaking: a configuration that loads on 0.27.0 can be refused.

Tests: test/workspace-pattern-forms.test.ts (new), two watcher tests on
each backend, the include's load-check and `\` tests turned round, and a
workspace-three-way comment on `ignore: ['a/b']` reworded (it now covers
the nested mount's directory too; the walk still counts it as that
mount's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants