Rename per review naming decisions: - package dsh-file-context → dsh-fs-policy (dir, package name, plugin name, tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph) - events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent (fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation" wording → "intent" throughout - exported FileContextExec → FsPolicyExec Make the implemented RFCs describe what shipped, not the superseded designs: the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext service prose is rewritten to the fs/* event-gate reality (freshness-based auth). Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the "new RFC" escape hatch is for macro decision reversals only, not renames. Code fixes from review: - fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a file) into the structured FsError taxonomy instead of leaking a raw Node error; resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to fail on the unfixed code. - tool-fs HMR test now asserts prompt sections (not just tool schemas) are withdrawn on disposal. - fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment, filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains" a throwing listener; a throw surfaces as the tool's isError result. - drop the false "loaded by the default product config" claim (no config wires the fs tools yet), the duplicate ctx.bash service-map row, the stale FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line; correct the dsh-fs package.json description.
@deepseek-ai/dsh-tool-fs
The model-facing filesystem tools — read, write, edit — and their executor. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, read windowing, and result formatting. It reads/writes/edits through the ctx.fs provider seam (@deepseek-ai/dsh-fs) directly — it injects fs (plus tools/systemPrompt), not a policy service. The freshness/observation policy is contributed by a separate plugin (@deepseek-ai/dsh-fs-policy) through the fs/* event gate; the tool is not method-coupled to it.
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
await ctx.plugin(ToolFs) // this package — registers read/write/edit
@deepseek-ai/dsh-fs-policy is optional: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
Tools (schemas per the filesystem tool schemas RFC)
| Tool | Arguments | Behavior |
|---|---|---|
read |
file_path, offset?, limit? |
Line-numbered UTF-8 content with a pagination footer. offset is 1-based; limit defaults to and caps at 2000 lines. |
write |
file_path, content |
Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior read at the unchanged version; creating a new file does not. Without it: unconditional. |
edit |
file_path, non-empty old_string, new_string, replace_all? |
Literal replacement; unique match required unless replace_all is true. With the policy plugin: requires a prior read (any window) and the file unchanged since. Without it: unconditional. |
Field names are snake_case to match Claude Code and existing harness tool schemas.
The tool is the executor; policy is an event gate
The tools do not inject a policy service or inspect any cache. Each tool resolves the path via ctx.fs.resolve(), then:
- read — one
ctx.fs.stat(type + size routing + version), thenreadText/streamText, then builds the line window, then emits a containedfs/observed. (1 stat.) - write —
ctx.waterfall('fs/write-intent', target, exec, () => undefined)for the optional guard, thenctx.fs.writeText(target, content, intent), thenfs/observed. (0 stat.) - edit —
ctx.waterfall('fs/edit-intent', target, exec, () => undefined)for the optional guard, thenctx.fs.editText(target, edit, intent), thenfs/observed. (0 stat.)
The tool passes exec (the tool-execution context) as the opaque actor on every dispatch. The default thunks return undefined (the unconstrained bare provider). When @deepseek-ai/dsh-fs-policy is loaded it occupies the single decision slot — returning createIfAbsent/replaceIfVersion/{ version } or throwing FS_NOT_OBSERVED — and records on fs/observed. Backend errors (FsError) and a thrown FS_NOT_OBSERVED flow through ToolRegistry.execute() and become isError tool results with their { name, code } attached.
fs/observed is fire-and-forget
fs/observed fires AFTER the read/write/edit already succeeded, via a plain ctx.emit. A listener is contractually a synchronous, side-effect-only recorder (@deepseek-ai/dsh-fs-policy's is a WeakMap.set); the tool does not guard the emit, so a listener that throws would surface as the tool's isError result — async or fallible observation does not belong on this event.
The read rendering (line windowing + output formatting) lives in src/read-render.ts (Cordis-free, independently unit-tested); src/read.ts/write.ts/edit.ts are the tool executors and src/index.ts composes them.