diff --git a/docs/architecture.md b/docs/architecture.md index 3539a0dae3..eddb1ea235 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ │ @deepseek-ai/dsh-fs-local (filesystem impl) │ -│ @deepseek-ai/dsh-file-context (filesystem policy gate) │ +│ @deepseek-ai/dsh-fs-policy (filesystem policy gate) │ │ @deepseek-ai/dsh-tool-fs (filesystem tools+executor)│ │ @deepseek-ai/dsh-subagent-* (subagent providers) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ @@ -60,7 +60,6 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | -| `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | | `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, atomic writes/edits (optional version guard); owns the `fs/*` policy events | | `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node | | `ctx.subagents` | `SubagentService` | dsh-subagent | named provider registry for delegating a task to child agents | @@ -79,7 +78,7 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. -The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. See [the file-context event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). +The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-fs-policy` is a policy PLUGIN (no service) that decides the `fs/write-intent`/`fs/edit-intent` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-fs-policy` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 7c1701af59..5a0f203e23 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -199,12 +199,12 @@ Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/t ### `fs/*` -#### `fs/edit-expectation` — waterfall +#### `fs/edit-intent` — waterfall -Single-slot decision: produce the optional version guard for the next FileSystem.editText. The tool dispatches this as an unbound waterfall and supplies a default thunk returning `undefined` (unconditional edit of the current content — the bare provider; no `stat`). The `@deepseek-ai/dsh-file-context` policy listener returns `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset or has not observed the target. Does NOT call `next()`: one decision, first-wins (see Events.'fs/write-expectation'). +Single-slot decision: produce the optional version guard for the next FileSystem.editText. The tool dispatches this as an unbound waterfall and supplies a default thunk returning `undefined` (unconditional edit of the current content — the bare provider; no `stat`). The `@deepseek-ai/dsh-fs-policy` policy listener returns `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset or has not observed the target. Does NOT call `next()`: one decision, first-wins (see Events.'fs/write-intent'). ```ts cordis-catalog -'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> +'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) @@ -213,7 +213,7 @@ Source: [`packages/fs/fs/src/index.ts:117`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit -Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`): the tool does not guard the emit, so a listener that throws surfaces as the tool's `isError` result, and cordis `emit` does not await listener promises — async or fallible audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. +Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be 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 surfaces as the tool's `isError` result, and cordis `emit` does not await listener promises — async or fallible audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. ```ts cordis-catalog 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -223,15 +223,15 @@ Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core- Source: [`packages/fs/fs/src/index.ts:129`](../../packages/fs/fs/src/index.ts) -#### `fs/write-expectation` — waterfall +#### `fs/write-intent` — waterfall -Single-slot decision: produce the write expectation for the next FileSystem.writeText. The tool dispatches this as an unbound waterfall (no `this`) and supplies a default thunk returning `undefined` (unconditional create-or-overwrite — the bare provider). The `@deepseek-ai/dsh-file-context` policy listener returns `createIfAbsent` (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` (observed) and does NOT call `next()` — one decision, not a composable chain. The slot is first-wins: the first non-`next()` decider (registration order, or `prepend`) occupies it; a second decider is a misconfiguration, not layering. `actor` is the opaque tool-execution context, never read here. +Single-slot decision: produce the write intent for the next FileSystem.writeText. The tool dispatches this as an unbound waterfall (no `this`) and supplies a default thunk returning `undefined` (unconditional create-or-overwrite — the bare provider). The `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` (observed) and does NOT call `next()` — one decision, not a composable chain. The slot is first-wins: the first non-`next()` decider (registration order, or `prepend`) occupies it; a second decider is a misconfiguration, not layering. `actor` is the opaque tool-execution context, never read here. ```ts cordis-catalog -'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise +'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` -Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) Source: [`packages/fs/fs/src/index.ts:105`](../../packages/fs/fs/src/index.ts) @@ -440,7 +440,7 @@ Semantics every backend must honor: - resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks). - stat returns FsInfo metadata (never content) or `undefined` when the target is absent. - readText/streamText read the whole regular text file (the stream for large files); both own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`. -- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteExpectation to guard the write. +- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write. - editText verifies `expected.version` BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement and writes atomically — all inside one mutation critical section. `expected` is OPTIONAL: omit it for an unconditional edit of the current content (a missing target still reports `FS_STALE_VERSION`). ```ts cordis-catalog @@ -448,11 +448,11 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) Source: [`packages/fs/fs/src/index.ts:158`](../../packages/fs/fs/src/index.ts) diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 22cda4672d..2e66dd9d9e 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,10 +1,10 @@ # Filesystem -The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-file-context](../../packages/fs/file-context), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. +The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-fs-policy](../../packages/fs/fs-policy), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. -The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-file-context` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. +The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. -Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). +Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). ## Target identity and metadata (provider seam) @@ -40,10 +40,10 @@ interface FsInfo { ## Write and edit guards (provider seam) -Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteExpectation` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. ```ts type-equiv -type FsWriteExpectation = +type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` @@ -75,16 +75,16 @@ interface FsEditOutcome { ## The fs policy events (provider-seam vocabulary) -`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. +`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. -`fs/write-expectation` and `fs/edit-expectation` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event whose listener must be synchronous and side-effect-only; the tool contains a throw so a recording bug never fails the already-completed mutation. The generated catalog shows the exact signatures on [events-and-services.md](../cordis-catalog/events-and-services.md). +`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener would surface as the tool's `isError` result for a mutation that already succeeded. The generated catalog shows the exact signatures on [events-and-services.md](../cordis-catalog/events-and-services.md). ## Execution context (policy plugin) -The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-file-context` import the tool, agent, or session packages. +The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. ```ts type-equiv -interface FileContextExec { +interface FsPolicyExec { agent?: { session?: object } @@ -108,7 +108,7 @@ interface FileReadOutcome { ## Observed-file state (policy plugin) -Observed state is a `WeakMap>` held inside the `dsh-file-context` plugin. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). +Observed state is a `WeakMap>` held inside the `dsh-fs-policy` plugin. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). ## Error taxonomy (provider seam) @@ -130,4 +130,4 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-file-context` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit expectation waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/module-graph.md b/docs/module-graph.md index 3fe0d5aefe..69391388ac 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -22,8 +22,8 @@ graph TD agent --> session compact --> llm compact --> session - file-context --> fs fs-local --> fs + fs-policy --> fs llm-replay --> llm llm-replay --> session session-persistence --> session @@ -120,8 +120,8 @@ graph TD | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | | `compact` | `llm`, `session` | -| `file-context` | `fs` | | `fs-local` | `fs` | +| `fs-policy` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `compact-basic` | `agent`, `compact`, `llm`, `session` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6d12cc9c3d..a79d15888c 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -98,7 +98,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | -| [Split the filesystem seam — provider text mutations plus policy `ctx.fileContext`](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | +| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | ### Architecture @@ -123,7 +123,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | -| [Make `dsh-file-context` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | +| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | ### Process diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index e5ddc5eeea..831b8d5325 100644 --- a/docs/rfc/implemented/AGENTS.md +++ b/docs/rfc/implemented/AGENTS.md @@ -10,6 +10,6 @@ Update it **in place** to state the current truth. Do **not** leave the outdated ### This is not a license to rewrite the *decision* -Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. If the underlying choice itself is reversed or materially changed (not just relocated), that is a new decision: write a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision"). The line: a refactor that moves where the decision is *realized* → edit this RFC to match; a reversal of *what was decided* → a new RFC. +Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. The "new RFC" escape hatch is for **macro** changes — a genuine reversal of *what was decided* or its rationale — NOT for renames, moves, or structural relocations. A rename is always a fact to fix **in place**: leaving a package/symbol/path at its old name (even with a "was renamed to…" aside) only confuses a reader who greps the current tree for a name that no longer exists. So: the package was renamed, a symbol changed, a plugin moved, the decision is now realized through a different mechanism → edit this RFC to state the current names and structure. Only a reversal of *what was decided* → a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision"). When in doubt, ask whether a reader following this RFC to the code would land on something real. If not, it needs updating. diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 0a4365bc82..731ee41ad7 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -20,19 +20,21 @@ We need the filesystem tools to land in the same capability-seam shape as bash b Introduce filesystem access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): -1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, filesystem vocabulary types, and file-state tracking contract. +1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, the filesystem vocabulary types, and the `fs/*` policy event vocabulary. 2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem. -3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. +3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`, and is the executor that dispatches the `fs/*` events. The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance. +The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`. This RFC established the three-package seam; the split of policy off the provider base class is decided by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate RFC](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape. + The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface. The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`. Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer. -Read-before-write/edit is part of the filesystem seam, not a separate service. `ctx.fs` records which file states the current execution context has seen and validates write-like operations against that state. The first `tool-fs` consumer passes the current tool execution context, or a structural projection of it, through to `ctx.fs`; `ctx.fs` derives the file-state owner from that context, normally `exec.agent.session`. `tool-fs` does not know the cache shape, the owner key, or the `read` tool name/schema. +Read-before-write/edit and observed-state are policy, contributed by the `dsh-fs-policy` plugin through the `fs/*` event gate — NOT stored on `ctx.fs`. The provider seam offers an optional version guard on its mutations (`writeText`/`editText` take an optional expectation); the policy plugin decides that guard by listening on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`. The executor (`dsh-tool-fs`) passes the current tool execution context as the opaque event actor; the policy plugin derives the observed-state owner from it, normally `exec.agent.session`. `dsh-fs` treats the actor as opaque and never reads it; `dsh-tool-fs` never reaches into the policy plugin. Authorization is version freshness: any read records the file's version, and a later write/edit is authorized as long as the file is unchanged. (This RFC first placed the observed-state store on `ctx.fs`; the split to `dsh-fs-policy` on the `fs/*` event gate is decided by [the split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.) ## Package topology @@ -43,9 +45,9 @@ The filesystem seam uses the same dependency direction as the bash trio: consumer interface implementation ``` -`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the file-state contract. The interface defines a minimal structural execution context shape rather than importing `dsh-tools`, `dsh-agent`, or `dsh-session`; the implementation derives a file-state owner from that shape when one is available. The owner object is opaque to `dsh-fs`: `tool-fs` may pass the `ToolExecution` it already receives, or a projected object containing only the owner-bearing fields, without making `dsh-fs` depend on the tool or agent packages. +`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the `fs/*` policy event vocabulary. It carries no observed-state store and no owner-derivation shape; the events pass an opaque `object` actor that the provider never reads, and the `dsh-fs-policy` plugin owns the owner-derivation shape and the observed-state store on top of those events. -`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, contains all direct `node:fs` / `node:path` access, and provides the in-memory file-state store for the local backend. +`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, and contains all direct `node:fs` / `node:path` access. It holds no observed-state store — freshness is a version token the backend mints and the policy plugin records. `@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`. @@ -62,15 +64,13 @@ The exact TypeScript signatures are implementation details for the PR, but the i - Create or replace a UTF-8 text file. - Edit an existing UTF-8 text file by literal replacement. -The interface must also cover file state: +The provider seam also carries the freshness hooks that policy builds on — but the observed-state store and owner derivation live in the `dsh-fs-policy` plugin, not on `ctx.fs`: -- Derive a file-state owner from the current execution context, normally the active agent session. -- Record that the owner saw a target at a backend-defined version. -- Determine whether that owner has a full editable view of a target. -- Use the recorded version as the stale guard for write/edit operations that require prior observation. -- Refresh the recorded state after a successful write/edit so follow-up modifications can proceed without forcing another read. +- The backend mints an opaque `version` token per target (in `stat` and in every read/mutation outcome). +- `writeText`/`editText` take an OPTIONAL version expectation: omit it for an unconditional bare-provider mutation, or supply it to guard the mutation inside the backend's atomic critical section. +- The `dsh-fs-policy` plugin decides that expectation on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`, keyed by an owner it derives from the opaque event actor (normally `exec.agent.session`). -The in-memory shape is conceptually a weakly-owned cache: file state is keyed first by the derived owner object, then by the backend `targetKey`. The owner is usually `exec.agent.session`, but `dsh-fs` treats it as opaque and does not import `dsh-session`. Each cached `FileState` records the `targetKey`, `displayPath`, backend `version`, current view (`full` or `partial`), update time, and source (`read`, `write`, `edit`, or a future seed path). Only a `full` view authorizes write/edit. A `partial` view records useful context (paged read, truncated read, injected context) but does not grant edit authority. +Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This RFC first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate RFCs replaced that with the freshness-based policy plugin described here.) Path resolution should be explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity. @@ -82,17 +82,17 @@ Resolved targets must expose at least three concepts: Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. `ctx.fs` records versions in its file-state store for stale checks; consumers may display related metadata but must not interpret the version token. -Text reads return structured UTF-8 line records or ranges with pagination metadata. `tool-fs` owns line-numbered model text rendering; the backend owns bounded line length, bounded output bytes, binary-file rejection, total-line accounting, and whether the returned content is a partial view of the file. +The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views. -When a read has a file-state owner, `ctx.fs` records the target, version, display path, view metadata, timestamp, and source. Partial views are useful context but do not authorize write/edit unless a future operation can prove the model saw the raw editable content. +Observed-state recording is not on `ctx.fs`: after a successful read the executor emits `fs/observed`, and the `dsh-fs-policy` plugin records `{ version }` for the deriving owner. There is no `full`/`partial` view — a read at any window records the version, and freshness (not view completeness) authorizes a later write/edit. -Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. For updates to existing files, `ctx.fs` should require a full prior file state for the current owner and reject absent or partial state. The backend then compares the current file version to the recorded version and rejects stale writes. If the recorded target no longer exists, the write is stale rather than a create. A create is expressed as a write to a target with no existing file and does not require prior state or a file-state owner. +Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. `writeText` takes an optional expectation: `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy uses for an unobserved owner); `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`; omitting the expectation is the unconditional bare-provider create-or-overwrite. The policy plugin chooses which expectation to supply from the owner's observed state. -Literal edit is part of `ctx.fs`, not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, prior-file-state checking, stale-version checking, and atomic read-modify-write are filesystem/backend semantics. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. +Literal edit is a provider primitive (`editText`), not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, optional stale-version checking, and atomic read-modify-write must stay together inside the backend's mutation critical section. `editText` takes the same optional version expectation; the stale check runs before literal matching so an edit against an old read reports `FS_STALE_VERSION`. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. -Direct tool executions without a derivable file-state owner can still exercise lower-level helpers in tests. Production `write`/`edit` tool calls should reject without an owner when they update an existing target, because those operations require prior state. Owner-less `write` may still create a new file when the backend confirms that the target does not already exist. +The policy plugin, not `ctx.fs`, gates on prior observation: an `edit` requires a prior observation by the owner (else `FS_NOT_OBSERVED`), and the recorded version is passed to `editText` as the CAS basis. With the policy plugin absent, `ctx.fs` alone is a complete unconstrained seam (unconditional write/edit); the tool is never method-coupled to the policy. -Filesystem contract failures are thrown as `FsError extends HarnessError` in the first implementation, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. Initial codes should include `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, and `FS_EDIT_NOT_FOUND`. +Filesystem contract failures are thrown as `FsError extends HarnessError`, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. The codes are `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, and `FS_ABORTED`. (An earlier draft included `FS_PARTIAL_OBSERVATION`; freshness-based authorization has no partial/full distinction, so it was dropped.) ## Tool consumer behavior @@ -115,7 +115,7 @@ The package registers prompt guidance through `ctx.systemPrompt.section(...)` an The tool package must keep model-facing contracts stable when backends change. A local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas should not change solely because the backend changes. -The first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. +The default deployment requires a prior `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran: it dispatches the `fs/write-intent`/`fs/edit-intent` events (passing the execution context as the opaque actor), and the `dsh-fs-policy` plugin derives the owner, gates on prior observation, and supplies the version expectation. Any windowed read authorizes a later write/edit as long as the file is unchanged. Creating a new file with `write` does not require prior observation. The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`. @@ -128,7 +128,7 @@ This RFC starts from `origin/master`, where no filesystem tool package exists ye 3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. 4. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. -This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so the `tool-fs` plugin gets the read-before-write/edit policy automatically. +This RFC's first landing kept the observed-state store behind `ctx.fs`. The split-fs-seam and event-gate RFCs then moved it into the standalone `@deepseek-ai/dsh-fs-policy` plugin on the `fs/*` event gate, which is the shipped shape; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit. Example leaf configs stay bash-only in this landing. Wiring `examples/coding-agent` or `examples/acp-agent` to `dsh-fs-local` + `dsh-tool-fs` changes the model prompt, visible tool schemas, and ACP snapshot transcript, so it should land as a follow-up UX/example change with prompt and snapshot updates in the same PR. @@ -146,7 +146,7 @@ Tests should follow the package boundary, not only the user-visible tools. `dsh-fs` tests cover the service seam itself: a provider registers `ctx.fs`, duplicate providers follow Cordis service behavior, disposal removes the service, and any shared contract helpers or type-level utilities behave as documented. -`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, pagination, output caps, binary-file rejection, abort handling, full-file create/update writes, owner-less creates, owner-less update rejection, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, file-state recording after reads, session/owner isolation, read-before-update rejection, stale-version rejection, partial-view rejection, structured `FsError` codes, and file-state refresh after successful writes/edits. +`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, streaming, binary-file rejection, invalid-UTF-8 rejection, abort handling, unconditional and version-guarded full-file writes, `createIfAbsent`/`replaceIfVersion` semantics, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, stale-version rejection (guarded edit against an old version), and structured `FsError` codes. The observed-state/owner-derivation policy is NOT here — it lives in `dsh-fs-policy` and is tested there. Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive-pattern classes this repo has been bitten by: @@ -156,9 +156,9 @@ Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive- - **Concurrency / stale races.** The RFC names edit as race-prone (see Risks). Test that two concurrent write/edit operations against the same target settle deterministically: one succeeds and the other is rejected with `FS_STALE_VERSION` rather than silently overwriting, and that a successful edit refreshes recorded state so an immediately-following edit by the same owner proceeds. - **HMR safety and disposal.** `dsh-fs-local` registers `ctx.fs` and owns the in-memory file-state store, so it needs its own HMR-safety test (register the backend on a fiber, dispose it, assert the `ctx.fs` provider is withdrawn and the file-state store is released — a later provider starts with no inherited state). -`dsh-tool-fs` tests cover the consumer surface with a fake `ctx.fs` implementation. They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, and HMR cleanup. +`dsh-tool-fs` tests cover the consumer surface against the real `dsh-fs-local` provider (mock only the model/clock, not the collaborator). They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit dispatch the `fs/*` events (passing the execution context as the actor), root-plugin suite registration, and HMR cleanup of both tool schemas and prompt sections. -Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. +Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` (and, for the default deployment, `dsh-fs-policy`) and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the packages work together without bypassing the tool registry — including a bare-provider path (no `dsh-fs-policy`) where an unread edit/overwrite succeeds. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. Repo gates for the implementation include the focused vitest suites, `pnpm run typecheck`, `pnpm run test:coverage` for runtime code, and build/publint coverage after adding package entrypoints. @@ -172,7 +172,7 @@ Repo gates for the implementation include the focused vitest suites, `pnpm run t **Edit semantics are race-prone.** Literal edit is a read-modify-write operation. Without a stale-content guard or backend-level atomic edit primitive, concurrent edits can overwrite each other. The first implementation should document its guarantees clearly; stronger compare-and-swap semantics can be added later if needed. -**File state inside `ctx.fs` can blur concerns.** Recording what an execution context has seen is workflow state, not raw filesystem I/O. This RFC still keeps it inside the filesystem seam because write/edit safety depends on backend-defined target identity and version tokens, and because putting it in `tool-fs` would couple write/edit to the read tool implementation. The boundary is narrow: `ctx.fs` derives the file-state owner, records file state, and checks stale versions, while `tool-fs` owns only model-facing schemas and formatting. +**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This RFC first placed it inside the filesystem seam; the split-fs-seam RFC then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider seam keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events. **The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index a51cde555d..3d5e67ce20 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -1,4 +1,4 @@ -# RFC: Make `dsh-file-context` an event-gate plugin, not a method interface +# RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface Status: implemented @@ -9,48 +9,48 @@ Status: implemented This couples three things that should be separable: 1. **What the tool does** — resolve a path, read a window, write/edit a file. This is the tool's job and needs only `ctx.fs`. -2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-file-context` plugin's job. +2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-fs-policy` plugin's job. 3. **The recording of observed state** — a side effect that should never block the tool from functioning. Because the tool calls `fileContext` methods, removing the policy layer is a breaking change rather than a graceful loss of an *add-on*. The policy is load-bearing for the tool to even run, not an opt-in tightening. ## Decision -Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-file-context` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service. +Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-fs-policy` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service. ```text tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs; emits fs policy events; renders results -policy dsh-file-context plugin: listens to fs/write-expectation + - fs/edit-expectation (single-slot waterfall) and fs/observed +policy dsh-fs-policy plugin: listens to fs/write-intent + + fs/edit-intent (single-slot waterfall) and fs/observed (emit) events; adds observed-state + freshness. provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version guard is OPTIONAL; owns the fs policy event vocabulary provider dsh-fs-local local implementation of ctx.fs ``` -The model is **additive, not subtractive**: `ctx.fs` on its own is a complete, unconstrained text-storage seam — `read` reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text in the current content. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-file-context` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-file-context` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-file-context`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. +The model is **additive, not subtractive**: `ctx.fs` on its own is a complete, unconstrained text-storage seam — `read` reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text in the current content. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. `dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. -## The policy is enforced by provider CAS, not by `dsh-file-context` stat +## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat -`dsh-file-context` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness: +`dsh-fs-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness: -- "Have you read this file?" is the one thing `dsh-file-context` decides locally — a `WeakMap` lookup, no I/O. No record ⇒ `FS_NOT_OBSERVED`. -- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-file-context` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. +- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record ⇒ `FS_NOT_OBSERVED`. +- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. -This is deliberate. If `dsh-file-context` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-file-context` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-file-context` only chooses the basis (`vObserved`) and gates on prior observation. +This is deliberate. If `dsh-fs-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation. ## Provider contract change: the version guard is optional For the bare provider to be unconstrained, the version guard on its two mutations becomes **optional** — present ⇒ guarded, absent ⇒ unconditional: ```ts ignore-check -// writeText: expected is now optional. The FsWriteExpectation union is UNCHANGED. -writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED. +writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise // undefined → unconditionally create-or-overwrite (bare default) -// createIfAbsent → create only, reject an existing file (dsh-file-context, unobserved) [unchanged] +// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged] // replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged] // editText: expected becomes optional (was the required { version: FsVersion }). @@ -60,22 +60,22 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion // { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) ``` -The `FsWriteExpectation` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-file-context` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment". +The `FsWriteIntent` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-fs-policy` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment". ## Event vocabulary (owned by `dsh-fs`) -The events live in `@deepseek-ai/dsh-fs`, not in `dsh-file-context`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-file-context` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-file-context` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin. +The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-fs-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-policy` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin. -These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteExpectation`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). +These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). -**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-file-context` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-expectation`, `fs/edit-expectation`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. +**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-fs-policy` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-intent`, `fs/edit-intent`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. -**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-file-context` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-expectation` decider BEFORE `dsh-file-context` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-file-context` as the policy decider. +**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-fs-policy` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-intent` decider BEFORE `dsh-fs-policy` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-fs-policy` as the policy decider. -The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-file-context`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. +The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. ```ts -import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' interface Events { /** @@ -85,7 +85,7 @@ interface Events { * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed). * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall */ - 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** * Single-slot decision: produce the optional version guard for the next * ctx.fs.editText. The default returns undefined (unconditional edit of the @@ -93,11 +93,11 @@ interface Events { * { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or * has not observed the target. Does NOT call next(): one decision. @mode waterfall */ - 'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** * Record that an actor observed a target at a version, after a successful * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be - * synchronous, side-effect-only recorders (`dsh-file-context`'s is a WeakMap + * synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap * write); the tool does not guard the emit, so a throwing listener surfaces as * the tool's isError result. No listener ⇒ nothing recorded. * @mode emit @@ -110,7 +110,7 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li ## Tool contract (`dsh-tool-fs`) -The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-file-context`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the file-context policy requires it. The bare-provider fallback does not change the prompt stance. +The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-fs-policy`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the fs-policy plugin requires it. The bare-provider fallback does not change the prompt stance. `dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read rendering** (`read-render.ts`: `buildWindow` + `formatReadOutput`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, plus `STREAM_MIN_SIZE` in `read.ts`), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. @@ -119,34 +119,34 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un `stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats: - **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock). -- **write** — `expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-file-context`. -- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. +- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. -The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-file-context` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-file-context` short-circuits the thunk before it runs in the default deployment. +The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment. -**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-file-context`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. +**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-fs-policy`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. -## Policy plugin contract (`dsh-file-context`) +## Policy plugin contract (`dsh-fs-policy`) -`dsh-file-context` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. +`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. -- `fs/write-expectation` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot. -- `fs/edit-expectation` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`. +- `fs/write-intent` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot. +- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`. - `fs/observed` listener: `record(owner, key, version)`. An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". This is what lets a create-then-edit or edit-then-edit sequence work without an intervening re-read: the mutation refreshes the recorded version to its own result, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety). -`dsh-file-context` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`. +`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`. -## Bare-provider behavior (no `dsh-file-context`) +## Bare-provider behavior (no `dsh-fs-policy`) -This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: +This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-fs-policy`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-fs-policy` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: - **read** is identical (it never needed policy; it only emits a now-unheard `fs/observed`). - **write** unconditionally creates-or-overwrites: `expected` is `undefined`, so `writeText` writes whether or not the file exists and whatever its current version. No read-first requirement, no version check. - **edit** unconditionally replaces literal text in the file's current content: `expected` is `undefined`, so `editText` matches and rewrites without a version guard or a read-first requirement (`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` still apply — those are about the literal match, not freshness). A missing target still reports `FS_STALE_VERSION`, matching the guarded edit path's "cannot edit this target now" code. -Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-file-context` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-file-context` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes. +Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-fs-policy` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-fs-policy` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes. ## Supersedes @@ -154,15 +154,15 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Acceptance Criteria -- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the `fs/observed` emit. Read rendering lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) +- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-intent`/`fs/edit-intent` waterfalls (passing `exec` as the actor) and the `fs/observed` emit. Read rendering lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) - `dsh-fs` declares the three events with `@mode` tags and an opaque `object` actor argument (no agent/session structure leaks into the provider vocabulary); the generated cordis catalog is regenerated. -- `dsh-file-context` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites). -- **Bare-provider test**: a config WITHOUT `dsh-file-context` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). -- **Single-slot semantics**: a test registers a second `fs/edit-expectation` listener AFTER `dsh-file-context` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant. +- `dsh-fs-policy` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites). +- **Bare-provider test**: a config WITHOUT `dsh-fs-policy` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-fs-policy` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). +- **Single-slot semantics**: a test registers a second `fs/edit-intent` listener AFTER `dsh-fs-policy` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant. - **Fire-and-forget recording**: `fs/observed` is emitted via a plain `ctx.emit` after the mutation succeeds; a listener is contractually synchronous and side-effect-only, so the tool does not guard it. -- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteExpectation` union is unchanged, and `dsh-file-context`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`. -- Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-file-context` performs no `stat`. -- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-file-context` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. +- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteIntent` union is unchanged, and `dsh-fs-policy`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`. +- Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-fs-policy` performs no `stat`. +- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-fs-policy` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. - Model-facing schemas stay byte-for-byte unchanged; snapshot transcript goldens are unaffected (or the diff is reviewed and re-recorded with justification). - Docs/artifacts updated in the same change: `docs/architecture.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, the split-fs-seam RFC's now-amended description, type-equiv blocks + manifest, cordis catalog, module graph. Gates green: `doc-sync`, `knip`, `test:coverage` (100% per-file). @@ -170,6 +170,6 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 - **Event indirection over a method call.** A waterfall + emit is less direct than `await ctx.fileContext.edit(...)`. The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each. - **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure. -- **Single policy occupant, first-wins by convention.** The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-file-context` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`. +- **Single policy occupant, first-wins by convention.** The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-fs-policy` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`. - **Dropping the post-read confirming stat** makes a follow-up *guarded* edit occasionally fail-closed (`FS_STALE_VERSION` → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes. -- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-file-context` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-file-context` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools. +- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-fs-policy` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-fs-policy` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 6e510c69b1..217c60cf63 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the three-package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`), and the observed-file/stale-version policy for read-before-write/edit checks. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`. +[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`, plus the `dsh-fs-policy` policy plugin), and the observed-file/stale-version policy for read-before-write/edit checks — which the [split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFCs moved off `ctx.fs` into the `dsh-fs-policy` plugin on the `fs/*` event gate. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`. The schema should be small enough to implement in the first `dsh-tool-fs` pass, but stable enough that future local/remote/sandboxed filesystem backends do not require model-facing churn. It should also avoid importing every option from reference systems. Claude Code and OpenCode expose similar core file tools but differ in naming style and extra flags; this RFC chooses the minimal shared surface for the prototype. @@ -15,10 +15,10 @@ The schema should be small enough to implement in the first `dsh-tool-fs` pass, | Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype | |---|---|---|---|---|---| | `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES | -| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Updates to existing files require prior observation through `ctx.fs`; new-file creates do not. | YES | -| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; requires prior full observation through `ctx.fs`. | YES | +| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES | +| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES | -The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into internal `ctx.fs` requests. +The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into `ctx.fs` calls and `fs/*` event dispatches. ## Tool schemas @@ -47,9 +47,9 @@ Arguments: - `file_path: string` — required. Path to write, resolved by `ctx.fs`. - `content: string` — required. Full UTF-8 text content to write. -For existing files, `write` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. Creating a new file does not require prior state or an owner. +Under the default fs-policy, updating an existing file with `write` requires a prior observation (a read/write/edit) of that file by the same execution context; the `dsh-fs-policy` plugin supplies the observed version as the stale guard on `fs/write-intent`. Creating a new file does not require a prior observation. With the policy plugin absent, `write` is an unconditional bare-provider create-or-overwrite. -The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by `ctx.fs` file state and backend-produced versions, not by asking the model to copy version tokens through the schema. +The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by backend-produced versions and the policy plugin's observed state, not by asking the model to copy version tokens through the schema. ### `edit` @@ -62,7 +62,7 @@ Arguments: - `new_string: string` — required. Literal replacement text; an empty string deletes the match. - `replace_all?: boolean` — optional. Defaults to false. When false, `old_string` must identify exactly one match. -`edit` requires a prior observation of the file in the same execution context (any windowed read counts — authorization is version freshness, not a full-view requirement), or a prior write/edit by that context. The `dsh-file-context` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it. +`edit` requires a prior observation of the file in the same execution context (any windowed read counts — authorization is version freshness, not a full-view requirement), or a prior write/edit by that context. The `dsh-fs-policy` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it. The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It uses one strict literal replacement mode so the model-facing contract stays simple and the backend can own exact-match, duplicate-match, line-ending, and stale-version semantics. @@ -99,15 +99,15 @@ The following are deliberately out of scope for the first filesystem schema pass - `write` requires `file_path` and `content`. - `edit` requires `file_path`, `old_string`, and `new_string`, accepts optional boolean `replace_all`, rejects empty `old_string`, and defaults `replace_all` to false. - The registered JSON schemas use the snake_case field names in this RFC. -- The tool descriptions accurately describe that existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. +- The tool descriptions accurately describe that, under the default fs-policy, existing-file `write` and `edit` require a prior observation (any windowed read counts) in the same execution context, while new-file `write` does not. - The `tool-fs` root plugin registers all three schemas. -Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. +Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` against the real `dsh-fs-local` provider and verify that model arguments are translated into the expected `ctx.fs` calls and `fs/*` dispatches. ## Risks **The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the first implementation focused, but users may ask for those quickly. They should be added as separate RFCs or focused follow-ups rather than overloaded into the initial schema. -**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and `ctx.fs` observed-file state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. +**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and the `dsh-fs-policy` plugin's observed state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. **Naming becomes public surface.** Once shipped, changing `file_path` to `filePath` or `old_string` to `oldString` would churn prompts, examples, and downstream clients. This RFC chooses snake_case up front and treats it as the stable model-facing contract. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 26bbb65056..736b65df08 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -1,4 +1,4 @@ -# RFC: Split the filesystem seam — provider text mutations plus policy `ctx.fileContext` +# RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin Status: implemented @@ -13,7 +13,7 @@ That makes every future backend reimplement model-facing read semantics and obse This also creates a real UX dead-end: a windowed read records `view: partial`, and partial views cannot authorize `edit`. A model that reads lines 100-150 of a large file therefore cannot edit line 120 unless it first gets a `full` read, which may be impossible for a file past the read cap. Literal edit only needs freshness: the bytes being matched must still be from the version the model read. -The old RFC already deferred a separate `@deepseek-ai/dsh-file-context` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec. +The old RFC already deferred a separate `@deepseek-ai/dsh-fs-policy` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec. ## Decision @@ -21,14 +21,14 @@ Split the stack into four layers: ```text tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events) -policy dsh-file-context observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) +policy dsh-fs-policy observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard) provider dsh-fs-local local implementation of ctx.fs ``` -`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fs` (not a policy service) and reaches `ctx.fs` directly, dispatching the `fs/*` policy events so `dsh-file-context` can gate and record. +`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It is the executor: it injects `fs` (not a policy service) and reaches `ctx.fs` directly, owns read windowing, and dispatches the `fs/*` events so `dsh-fs-policy` can gate and record. -The tool↔policy COUPLING below was reworked by [the file-context event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-file-context` is now a gate PLUGIN that participates through the `fs/*` events (no `ctx.fileContext` service), and read windowing + the fs I/O moved up into `dsh-tool-fs`. The four-layer split, the provider contract, and the freshness *policy* this RFC decided are unchanged. Read the "`ctx.fileContext.read`/`write`/`edit`" method descriptions below as the policy DECISIONS the gate plugin now makes on the `fs/*` events, and the provider's version guard as optional (omit = unconditional bare provider). +This RFC decided the four-layer split, the provider contract, and the freshness policy. The tool↔policy COUPLING was then refined by [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-fs-policy` is a gate PLUGIN that participates through the `fs/*` events rather than a `ctx.fileContext` method service, so the tool is not method-coupled to it and read windowing + the fs I/O live in `dsh-tool-fs`. This document describes that landed event-gate shape; the provider's version guard is optional (omit = unconditional bare provider). ## Provider Contract @@ -39,7 +39,7 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise interface FsInfo { @@ -48,18 +48,18 @@ interface FsInfo { size?: number } -type FsWriteExpectation = +type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` -`stat` returns metadata, not content. `version` is the freshness token; `type` lets the policy reject directories/special files before reading; `size` lets `ctx.fileContext.read` choose `readText` vs `streamText` without probing by failure. `undefined` means absent. +`stat` returns metadata, not content. `version` is the freshness token; `type` lets the executor reject directories/special files before reading; `size` lets the `read` tool choose `readText` vs `streamText` without probing by failure. `undefined` means absent. `readText` reads the whole regular text file. `streamText` streams the same text semantics for large files. Both provider primitives own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`; the policy layer never handles raw bytes or reimplements cross-chunk decoding. `readText` is the small-file/direct whole-file primitive, while large model-facing reads use `streamText`. `writeText` is atomic temp-file + rename with an explicit write expectation. `createIfAbsent` creates a missing target and rejects an existing target with `FS_NOT_OBSERVED`; it is the path used when the owner has no prior read. `replaceIfVersion` replaces only when the target exists at the observed version; a missing target or version mismatch throws `FS_STALE_VERSION`. -`editText` is a provider-level guarded text mutation. It first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam also preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing `ctx.fileContext` to pull the whole file through the policy layer. +`editText` is a provider-level guarded text mutation. When guarded it first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing the policy layer to pull the whole file through it. This is a *text-storage* seam, deliberately half a level above byte-level fsspec (`cat`/`open` hand back raw bytes). UTF-8 decoding, binary/NUL rejection, guarded full-file writes, and guarded literal text edits live in the provider so the policy layer never touches raw bytes, reimplements cross-chunk decoding, or separates stale checks from the mutation critical section. Model-facing concepts still stay out of the provider: no line windows, numbered lines, rendered footers, or observed-state store leak down. @@ -67,23 +67,25 @@ Deleted from `dsh-fs`: `readPage`, `FsExpectation`, `FsView`, `FsStateSource`, ` ## Policy Contract -`@deepseek-ai/dsh-file-context` registers concrete service `ctx.fileContext` and injects `fs`. It is a concrete service, not a seam: it owns the read-windowing and write/edit freshness policy that does not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). +`@deepseek-ai/dsh-fs-policy` is a plugin, not a service: it registers no `ctx.*` key and injects nothing. It owns the write/edit freshness policy and observed-state that do not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). It contributes that policy through the `fs/*` event gate the executor dispatches. (This RFC originally proposed a concrete `ctx.fileContext` service with `read`/`write`/`edit` methods; [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) refined it into the plugin described here so the tool is never method-coupled to the policy.) -Observed state lives here as `WeakMap>`. An entry exists iff the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag. The owner is still derived structurally from `{ agent?: { session? } }`, but that shape no longer belongs to `dsh-fs`. +Observed state lives here as `WeakMap>`. An entry exists iff the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence *is* the prior-observation record — there is no separate `hasRead` flag. The owner is derived structurally from the opaque event actor (`{ agent?: { session? } }`), a shape that lives in `dsh-fs-policy`, not `dsh-fs`. -`read(target, request, exec?, signal?)` is the only read path used by the model-facing `read` tool. It stats the target, rejects absent/non-regular targets, chooses `readText` or `streamText` from `FsInfo`, builds the requested line window from text chunks, records `{ version: info.version }`, and returns the structured outcome that the tool renders. +The plugin decides three `fs/*` events: -`write(target, content, exec?, signal?)` uses freshness policy: no recorded read calls `writeText({ kind: 'createIfAbsent' })`, so only new files can be created blindly; a recorded read calls `writeText({ kind: 'replaceIfVersion', version: vObserved })`, so existing files are replaced only if they are unchanged since the read. A successful write refreshes recorded state from the returned outcome or a post-write `stat`. +- `fs/write-intent` — no prior observation ⇒ `{ kind: 'createIfAbsent' }` (only new files can be created blindly); a prior observation ⇒ `{ kind: 'replaceIfVersion', version: vObserved }` (existing files replaced only if unchanged since the observation). Single-slot decision; does not call `next()`. +- `fs/edit-intent` — requires a prior observation by the owner (else `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. It does not implement literal replacement — it authorizes and supplies the version, and the provider's mutation critical section applies the guard, so concurrent edits based on the same observed version remain one-wins/one-stale. +- `fs/observed` — records `{ version }` for this owner+target after a successful read/write/edit. Synchronous, side-effect-only `WeakMap.set`. -`edit(target, edit, exec?, signal?)` requires a recorded read at `vObserved`, then calls `ctx.fs.editText(target, edit, { version: vObserved })` and refreshes recorded state from the returned version. `ctx.fileContext` does not implement literal replacement itself; it authorizes the operation and passes the observed version to the provider. The provider owns the mutation critical section, so concurrent edits based on the same observed version remain one-wins/one-stale rather than being merged or re-applied. If a backend needs a defensive whole-file edit cap, it should surface that as the same filesystem error taxonomy, but large model-facing reads should stream instead of failing just because the file is large. +The plugin does NO filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — the plugin only supplies `vObserved` as the basis. ## Tool Contract -`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. +`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. It is the executor: it validates model args, reads/writes/edits through `ctx.fs` directly, owns line windowing and result rendering (`N: text`, footer, `/` envelope), and dispatches the `fs/*` events. -The tool package only validates model args, calls `ctx.fileContext`, and renders results (`N: text`, footer, `/` envelope). The no-bypass rule is part of the contract: a model-facing `read` must call `ctx.fileContext.read`, never `ctx.fs.readText` or `ctx.fs.streamText`, so every successful read records observed-state before rendering. +Each mutation dispatches its intent waterfall with an `undefined` bare-provider default, then calls `ctx.fs`, then emits `fs/observed`: e.g. `write` does `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`. A `read` stats once, reads/streams, builds the window, and emits `fs/observed`. Passing `exec` as the actor lets `dsh-fs-policy` derive the owner without the tool reaching into the policy. -Direct `ctx.fs` calls are still allowed for non-tool consumers. They are explicit escape hatches: a direct `ctx.fs.readText` records no observed-state, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. +Because the policy is contributed through events with an `undefined` default, `dsh-tool-fs` is not method-coupled to `dsh-fs-policy`: with the plugin absent, every intent waterfall falls through to `undefined` (unconditional bare-provider write/edit) and `fs/observed` has no listener. Loading the plugin back layers the read-before-write/edit policy on. ## Concurrency Boundary @@ -97,7 +99,7 @@ Cross-process writes are best-effort freshness plus atomic replacement: `mtime:s This RFC reverses two decisions from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) and narrows a third: -- Read-before-write/edit policy moves out of `ctx.fs` and into `ctx.fileContext`. +- Read-before-write/edit policy moves out of `ctx.fs` and into the `dsh-fs-policy` plugin (on the `fs/*` event gate). - Text reads no longer return backend-numbered line records or `full`/`partial` views; authorization is based on version freshness, so a windowed read can authorize edit when the file is unchanged. - Literal edit no longer sits behind the old `applyEdit` API that mixed backend mutation with seam-owned observation policy. It remains a provider primitive as `editText`, because version guard + literal match + atomic rewrite must stay inside the provider's mutation critical section. @@ -105,8 +107,8 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Acceptance Criteria -- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. -- `dsh-file-context` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.) +- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteIntent` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. +- `dsh-fs-policy` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.) - `dsh-tool-fs` reaches the policy decisions and model-facing schemas stay byte-for-byte unchanged; the observation contract (a read records observed-state; a direct `ctx.fs` read does not) is documented and tested. (The tool injects `fs` and dispatches the `fs/*` events rather than injecting a `fileContext` service, per the event-gate RFC.) - Windowed read authorizing edit is shown to fail on the pre-refit code and pass after the refit. Existing version-CAS behavior is preserved with a regression test; it is not claimed as a pre-refit failure. An edit based on a stale read must report `FS_STALE_VERSION` before attempting literal matching. - `dsh-fs-local` carries no line, view, or `formatReadBody` logic; it does carry provider-level `editText` logic. @@ -116,7 +118,7 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Risks - Adds a fourth fs package and a new service. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam. -- Direct `ctx.fs` use can surprise callers who later use `ctx.fileContext`. The failure is explicit (`FS_NOT_OBSERVED`) and documented. -- Large-file line windowing moves from the backend to `ctx.fileContext.read`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation. +- Direct `ctx.fs` use bypasses the policy: a direct `ctx.fs.readText` emits no `fs/observed`, so under the default policy a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through the `read` tool. The failure is explicit and documented. +- Large-file line windowing moves from the backend to the `read` tool in `dsh-tool-fs`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation. - Keeping `editText` in the provider seam means every backend must implement the literal replacement contract. This is intentional: the operation is not pure storage, but stale guard + literal match + atomic rewrite is the unit that must stay together for correct error attribution and concurrency behavior. The contract should stay narrow and text-only so future backends can implement it natively or by whole-file rewrite. - Freshness permits full-file `write` after a windowed read. That is weaker than the old view check, but avoids making large files impossible to edit; prompt guidance should still discourage blind full replaces. diff --git a/packages/README.md b/packages/README.md index 1ecdb8c9eb..04776937eb 100644 --- a/packages/README.md +++ b/packages/README.md @@ -38,7 +38,7 @@ dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events) dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-file-context ← dsh-fs (observed-state + freshness policy gate, no service) +dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service) dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor) dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter) @@ -78,7 +78,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | | `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` | | `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | | `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | | `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | diff --git a/packages/fs/README.md b/packages/fs/README.md index 0fbce830e3..985a9f3ad6 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -6,7 +6,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona |---|---|---| | `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | -The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`file-context/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 60ad805938..ca5a7bb09e 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -6,7 +6,7 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) -// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for the +// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the // freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. ``` diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 61cc3fee1a..b24b7e6b89 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -5,7 +5,7 @@ * * This is the PROVIDER layer: it hands back decoded whole-file text (validated * UTF-8, binary rejected) — never line windows or numbered lines, which are - * model-facing read policy owned by `@deepseek-ai/dsh-file-context`. Large files + * model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files * stream their text in chunks so a huge file never has to be held whole in * memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here. * @@ -35,6 +35,16 @@ function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } +/** + * A path component that is expected to be a directory is a regular file (e.g. + * resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target + * cannot exist — so the resolution/probe paths treat it as "absent" rather than + * letting a raw Node error escape without the structured `FsError` taxonomy. + */ +function isENOTDIR(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOTDIR' +} + function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } @@ -119,6 +129,10 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } } catch (error: unknown) { - /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ - if (!isENOENT(error)) throw error + // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean + // the target is absent; any other stat failure is a real permission/IO fault. + /* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */ + if (!isENOENT(error) && !isENOTDIR(error)) throw error return null } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 9f769bc7cf..7a65148a99 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -21,7 +21,7 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' import { @@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem { override async writeText( target: FsTarget, content: string, - expected?: FsWriteExpectation, + expected?: FsWriteIntent, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 4119188f1b..d149e741b0 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -3,7 +3,7 @@ * file/streamed text reads, atomic guarded writes (createIfAbsent / * replaceIfVersion), version-guarded literal edits, concurrency races, symlink * identity, and HMR/disposal. Read WINDOWING is policy and lives in - * `dsh-file-context`, so it is not exercised here. + * `dsh-fs-policy`, so it is not exercised here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 33f26f797a..6f28d54402 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -2,7 +2,7 @@ * Cordis-free tests for the raw local-filesystem I/O: path resolution, probe, * whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp * safety, literal edit matching, and line-ending handling. Line WINDOWING is - * policy and lives in `dsh-file-context`, so it is not tested here. + * policy and lives in `dsh-fs-policy`, so it is not tested here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -21,7 +21,7 @@ import { writeFileAtomic, } from '@deepseek-ai/dsh-fs-local' import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' -import { FsTargetKey } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' let dir: string beforeEach(async () => { @@ -88,6 +88,16 @@ describe('resolveLocalTarget', () => { it('rejects a blank path', async () => { await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) }) + + it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => { + // "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath; + // the raw Node error must be translated into the FsError taxonomy so the tool + // result keeps its { name, code } metadata. + await writeFile(join(dir, 'afile'), 'i am a file') + const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e) + expect(err).toBeInstanceOf(FsError) + expect(err).toMatchObject({ code: 'FS_NOT_FOUND' }) + }) }) describe('probe', () => { @@ -128,6 +138,11 @@ describe('probe', () => { await new Promise((resolve) => { server.close(() => { resolve() }) }) } }) + + it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => { + await writeFile(join(dir, 'afile'), 'i am a file') + expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull() + }) }) describe('readWholeText', () => { diff --git a/packages/fs/file-context/README.md b/packages/fs/fs-policy/README.md similarity index 60% rename from packages/fs/file-context/README.md rename to packages/fs/fs-policy/README.md index b543722055..ad912bfc95 100644 --- a/packages/fs/file-context/README.md +++ b/packages/fs/fs-policy/README.md @@ -1,10 +1,10 @@ -# @deepseek-ai/dsh-file-context +# @deepseek-ai/dsh-fs-policy -The **file-context policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fileContext` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. +The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. ```ts import type { Context } from 'cordis' -import * as FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' declare const ctx: Context @@ -12,8 +12,8 @@ declare const ctx: Context // Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the // @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin // decides. Order does not matter for resolution (no inject), but the policy -// listener should be the first decider registered for the fs/*-expectation slots. -await ctx.plugin(FileContext) +// listener should be the first decider registered for the fs/*-intent slots. +await ctx.plugin(FsPolicy) ``` ## The four-layer split @@ -21,7 +21,7 @@ await ctx.plugin(FileContext) | Layer | Package | Role | |---|---|---| | tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | -| policy | `@deepseek-ai/dsh-file-context` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | | provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary | | provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` | @@ -31,8 +31,8 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek | Event | This plugin's listener | |---|---| -| `fs/write-expectation` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | -| `fs/edit-expectation` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | +| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | +| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | | `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. | ## Observed state is the prior-observation record; freshness is provider CAS @@ -41,7 +41,7 @@ Observed state is a `WeakMap>`. An entry exists ## Single-slot, first-wins -The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`. +The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`. ## No method coupling diff --git a/packages/fs/file-context/package.json b/packages/fs/fs-policy/package.json similarity index 95% rename from packages/fs/file-context/package.json rename to packages/fs/fs-policy/package.json index 16ee567305..c3f2a07982 100644 --- a/packages/fs/file-context/package.json +++ b/packages/fs/fs-policy/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-file-context", + "name": "@deepseek-ai/dsh-fs-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)", "version": "0.0.1", "private": true, diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/fs-policy/src/index.ts similarity index 79% rename from packages/fs/file-context/src/index.ts rename to packages/fs/fs-policy/src/index.ts index 5e0488ea0e..4d5c7964b7 100644 --- a/packages/fs/file-context/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -1,10 +1,10 @@ /** - * The file-context policy PLUGIN: observed-state, read-before-edit, and + * The fs-policy PLUGIN: observed-state, read-before-edit, and * "write/edit must be based on the version you read" — added on top of the * `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method - * service. This plugin registers NO `ctx.fileContext` service and exposes no + * service. This plugin registers NO `ctx.fsPolicy` service and exposes no * `read`/`write`/`edit`/`resolve` methods; it influences the world only by - * deciding the `fs/write-expectation`/`fs/edit-expectation` waterfalls and + * deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and * recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs` * (the executor) free of any method coupling to the policy layer — removing * this plugin gracefully loses the policy and leaves the unconstrained bare @@ -33,22 +33,22 @@ * * ## Single-slot, first-wins * - * The `fs/write-expectation`/`fs/edit-expectation` listeners do NOT call + * The `fs/write-intent`/`fs/edit-intent` listeners do NOT call * `next()`: each fully decides its single slot. The slot is first-wins by * registration order — this plugin owning it is the default-deployment * convention, not an event-enforced invariant (a decider registered before / * `prepend`ed would win instead). This is not a composable authorization chain; * layered permission/audit/sandbox interception belongs on `tools/execute`. * - * @module @deepseek-ai/dsh-file-context + * @module @deepseek-ai/dsh-fs-policy */ import type { Context } from 'cordis' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' -import type { FileContextExec } from './types.ts' +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsPolicyExec } from './types.ts' -export type { FileContextExec } from './types.ts' +export type { FsPolicyExec } from './types.ts' /** * Per-context observed-file state and the three `fs/*` decisions over it. One @@ -69,7 +69,7 @@ class ObservedStateGate { * the write/edit prior-observation policy. */ private owner(actor: object | undefined): object | undefined { - return (actor as FileContextExec | undefined)?.agent?.session + return (actor as FsPolicyExec | undefined)?.agent?.session } private get(owner: object, targetKey: string): FsVersion | undefined { @@ -91,11 +91,11 @@ class ObservedStateGate { } /** - * Decide the write expectation: no prior observation ⇒ `createIfAbsent` (only + * Decide the write intent: no prior observation ⇒ `createIfAbsent` (only * new files can be created blindly); a prior observation ⇒ `replaceIfVersion` * at the observed version (existing files replaced only if unchanged). */ - writeExpectation(target: FsTarget, actor: object | undefined): FsWriteExpectation { + writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' } @@ -105,7 +105,7 @@ class ObservedStateGate { * Decide the edit version guard: requires a prior observation by this owner * (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis. */ - editExpectation(target: FsTarget, actor: object | undefined): { version: FsVersion } { + editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined if (!owner || !prior) { @@ -122,7 +122,7 @@ class ObservedStateGate { } /** Cordis plugin name used by loader diagnostics. */ -export const name = 'file-context' +export const name = 'fs-policy' /** * Register the three `fs/*` listeners. No `inject` — this plugin reads no @@ -138,21 +138,22 @@ export function apply(ctx: Context): void { // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the // release observable and immediate for tests. gate.clear() - }, 'file-context observed-state teardown') + }, 'fs-policy observed-state teardown') - // fs/write-expectation: occupy the single decision slot — do NOT call next(). + // fs/write-intent: occupy the single decision slot — do NOT call next(). // Deferred through Promise.resolve().then so the declared Promise return type // holds (a throw rejects, never escapes synchronously through the waterfall). - ctx.on('fs/write-expectation', (target, actor) => Promise.resolve().then(() => gate.writeExpectation(target, actor))) + ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor))) - // fs/edit-expectation: occupy the single decision slot — do NOT call next(). + // fs/edit-intent: occupy the single decision slot — do NOT call next(). // Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise // the edit tool's `await ctx.waterfall(...)` surfaces as its isError result. - ctx.on('fs/edit-expectation', (target, actor) => Promise.resolve().then(() => gate.editExpectation(target, actor))) + ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor))) - // fs/observed: synchronous, side-effect-only WeakMap write (cannot throw under - // normal operation); the tool contains any throw so a record bug never fails - // the already-completed mutation. + // fs/observed: synchronous, side-effect-only WeakMap write. The tool emits + // this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw — + // a throw would surface as the tool's isError result for a mutation that + // already succeeded. A WeakMap.set honors that contract. ctx.on('fs/observed', (target, version, actor) => { gate.observe(target, version, actor) }) diff --git a/packages/fs/file-context/src/types.ts b/packages/fs/fs-policy/src/types.ts similarity index 86% rename from packages/fs/file-context/src/types.ts rename to packages/fs/fs-policy/src/types.ts index b3157cc2e9..9ee742a7f7 100644 --- a/packages/fs/file-context/src/types.ts +++ b/packages/fs/fs-policy/src/types.ts @@ -1,5 +1,5 @@ /** - * Vocabulary for the file-context policy plugin: the minimal execution-context + * Vocabulary for the fs-policy plugin: the minimal execution-context * shape used to derive an observed-state owner by narrowing the opaque `object` * actor the `fs/*` events carry. * @@ -7,7 +7,7 @@ * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state * owner structure on top of it. * - * @module @deepseek-ai/dsh-file-context/types + * @module @deepseek-ai/dsh-fs-policy/types */ /** @@ -20,7 +20,7 @@ * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. */ -export interface FileContextExec { +export interface FsPolicyExec { /** The agent on whose behalf the call runs, when there is one. */ agent?: { /** The session that owns observed-file state, used as an opaque key. */ diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts similarity index 56% rename from packages/fs/file-context/tests/policy.spec.ts rename to packages/fs/fs-policy/tests/policy.spec.ts index 63808f1610..02bb934cfd 100644 --- a/packages/fs/file-context/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for the file-context policy PLUGIN: it registers no service, only the + * Tests for the fs-policy PLUGIN: it registers no service, only the * three `fs/*` listeners. We dispatch those events directly (the unbound * waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the * decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread @@ -7,87 +7,87 @@ * multi-owner isolation, single-slot first-wins, and disposal/HMR release. * * No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only - * decides expectations and records versions on its own WeakMap. + * decides intents and records versions on its own WeakMap. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsWriteExpectation } from '@deepseek-ai/dsh-fs' -import * as FileContext from '@deepseek-ai/dsh-file-context' -import type { FileContextExec } from '@deepseek-ai/dsh-file-context' +import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy' function target(path: string): FsTarget { return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } } -const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) +const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } }) -/** Dispatch the write-expectation waterfall with the bare default thunk. */ -function writeExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise { - return ctx.waterfall('fs/write-expectation', t, actor, () => undefined) +/** Dispatch the write-intent waterfall with the bare default thunk. */ +function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise { + return ctx.waterfall('fs/write-intent', t, actor, () => undefined) } -/** Dispatch the edit-expectation waterfall with the bare default thunk. */ -function editExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { - return ctx.waterfall('fs/edit-expectation', t, actor, () => undefined) +/** Dispatch the edit-intent waterfall with the bare default thunk. */ +function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { + return ctx.waterfall('fs/edit-intent', t, actor, () => undefined) } async function setup() { const ctx = new Context() - const fiber = await ctx.plugin(FileContext) + const fiber = await ctx.plugin(FsPolicy) return { ctx, fiber } } describe('registration / disposal', () => { - it('registers no service surface (it is a plugin, not ctx.fileContext)', async () => { + it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => { const { ctx } = await setup() - expect((ctx as Context & { fileContext?: unknown }).fileContext).toBeUndefined() + expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined() }) it('mounts with no inject (reads no services)', async () => { // It mounts immediately even with nothing else in the context. const ctx = new Context() - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) // The listener is live: an unobserved write decides createIfAbsent. - expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) }) }) -describe('write-expectation decision', () => { +describe('write-intent decision', () => { it('an unobserved target decides createIfAbsent', async () => { const { ctx } = await setup() - expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) }) it('a no-owner actor decides createIfAbsent', async () => { const { ctx } = await setup() - expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) - expect(await writeExpectation(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' }) }) it('an observed target decides replaceIfVersion at the observed version', async () => { const { ctx } = await setup() const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec) - expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) }) }) -describe('edit-expectation decision', () => { +describe('edit-intent decision', () => { it('rejects an unread edit with FS_NOT_OBSERVED', async () => { const { ctx } = await setup() - await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) it('rejects an edit with no owner (cannot prove prior observation)', async () => { const { ctx } = await setup() - await expect(editExpectation(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) it('returns the observed version as the CAS basis after an observation', async () => { const { ctx } = await setup() const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) }) }) @@ -96,7 +96,7 @@ describe('observed-state is the prior-observation record', () => { const { ctx } = await setup() const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read - expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => { @@ -104,17 +104,17 @@ describe('observed-state is the prior-observation record', () => { const exec = ownerExec({}) // A create records v1; the follow-up edit guards against v1 with no read. ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) // The edit records v2; a second edit guards against v2. ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) }) it('a no-owner observation records nothing', async () => { const { ctx } = await setup() ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined) // Still unobserved for any owner. - await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) }) @@ -124,8 +124,8 @@ describe('multi-owner isolation', () => { const a = ownerExec({}) const b = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) - await expect(editExpectation(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - expect(await editExpectation(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) + await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) }) it('each owner records its own observed version independently', async () => { @@ -134,8 +134,8 @@ describe('multi-owner isolation', () => { const b = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0 // B never observed → createIfAbsent; A still holds v0 → replaceIfVersion. - expect(await writeExpectation(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) - expect(await writeExpectation(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) }) @@ -143,27 +143,27 @@ describe('single-slot, first-wins', () => { it('fully decides the slot without calling next() (the bare default is unreached)', async () => { const { ctx } = await setup() let defaultRan = false - const expectation = await ctx.waterfall('fs/write-expectation', target('a.txt'), ownerExec({}), () => { + const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => { defaultRan = true return undefined }) - expect(expectation).toEqual({ kind: 'createIfAbsent' }) + expect(intent).toEqual({ kind: 'createIfAbsent' }) expect(defaultRan).toBe(false) }) - it('a SECOND decider registered AFTER file-context is not reached (first-wins short-circuit)', async () => { + it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => { const { ctx } = await setup() let secondRan = false - // Registered after file-context, so it dispatches second; file-context does + // Registered after fs-policy, so it dispatches second; fs-policy does // not call next(), so this never runs. (A decider registered BEFORE — or with // prepend — would instead win: first-wins is by convention, not enforced.) - ctx.on('fs/edit-expectation', () => { + ctx.on('fs/edit-intent', () => { secondRan = true return Promise.resolve(undefined) }) const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) - await editExpectation(ctx, target('a.txt'), exec) + await editIntent(ctx, target('a.txt'), exec) expect(secondRan).toBe(false) }) }) @@ -172,21 +172,21 @@ describe('disposal releases recorded state (HMR safety)', () => { it('a fresh plugin after disposal starts with no inherited state', async () => { const ctx = new Context() const exec = ownerExec({}) - const fiber = await ctx.plugin(FileContext) + const fiber = await ctx.plugin(FsPolicy) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) await fiber.dispose() - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) // Same owner object, but state was released on disposal. - await expect(editExpectation(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) it('no listeners remain after disposal (the gate no longer decides)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(FileContext) + const fiber = await ctx.plugin(FsPolicy) await fiber.dispose() // With no listener, the waterfall falls through to the bare default. - expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() + expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() }) }) diff --git a/packages/fs/file-context/tsconfig.json b/packages/fs/fs-policy/tsconfig.json similarity index 100% rename from packages/fs/file-context/tsconfig.json rename to packages/fs/fs-policy/tsconfig.json diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index fd2307dec5..917b660cfa 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -7,7 +7,7 @@ This package is the provider-seam layer of the four-layer filesystem stack, spli | Layer | Package | Role | |---|---|---| | tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | -| policy | `@deepseek-ai/dsh-file-context` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | | provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary | | provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation | @@ -23,22 +23,21 @@ A backend subclasses `FileSystem` and implements six primitives. | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | -| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteExpectation` (`createIfAbsent`/`replaceIfVersion`) to guard. | +| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. | | `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. | The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity. ## The `fs/*` policy events -This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-expectation` and `fs/edit-expectation` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. +This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. ## A provider seam, not the policy layer -`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-file-context`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy. +`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy. `editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit. ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteExpectation` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. - +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 395816dbf9..813cb04e16 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-fs", - "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract", + "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index f25a521301..e4aef709d0 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -2,7 +2,7 @@ * The filesystem provider seam (`ctx.fs`): an abstract service defining the * text-storage primitives a backend provides — resolve a path into a stable * target, stat its metadata, read/stream its text, write it atomically with an - * explicit expectation, and apply a guarded literal edit — without saying HOW. + * explicit intent, and apply a guarded literal edit — without saying HOW. * Implementations subclass {@link FileSystem} and register themselves as the * `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first. * Future implementations swap in sandboxed, remote, virtual, or project-scoped @@ -20,7 +20,7 @@ * literal-edit critical section — but NOT line windows, numbered lines, * rendered footers, or observed-state. Read windowing lives in the model-facing * tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit - * are policy a plugin (`@deepseek-ai/dsh-file-context`) adds through the `fs/*` + * are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*` * event gate. So a sandboxed/remote backend inherits no model-facing observation * policy it has no business carrying. * @@ -41,14 +41,14 @@ * unconditional write/edit is still atomic; "unconditional" drops the *version* * precondition, not the atomicity. Observed-state, read-before-edit, and * version-guarded write/edit are NOT provider behavior — they are policy a - * plugin (`@deepseek-ai/dsh-file-context`) adds on top by supplying the guard. + * plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard. * * ## The fs policy events live here, not in the policy plugin * - * This package owns the `fs/write-expectation`, `fs/edit-expectation`, and + * This package owns the `fs/write-intent`, `fs/edit-intent`, and * `fs/observed` event vocabulary (see {@link Events}). The emitter is * `@deepseek-ai/dsh-tool-fs` and the default listener is - * `@deepseek-ai/dsh-file-context`; the events live in the one package both + * `@deepseek-ai/dsh-fs-policy`; the events live in the one package both * already depend on, so the emitter shares a vocabulary with the policy listener * without depending on the policy plugin. The events carry only `dsh-fs` * vocabulary plus an opaque `object` actor — no model-facing concepts (line @@ -64,7 +64,7 @@ import type { FsInfo, FsTarget, FsVersion, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from './types.ts' @@ -79,7 +79,7 @@ export type { FsErrorCode, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from './types.ts' @@ -90,11 +90,11 @@ declare module 'cordis' { interface Events { /** - * Single-slot decision: produce the write expectation for the next + * Single-slot decision: produce the write intent for the next * {@link FileSystem.writeText}. The tool dispatches this as an unbound * waterfall (no `this`) and supplies a default thunk returning `undefined` * (unconditional create-or-overwrite — the bare provider). The - * `@deepseek-ai/dsh-file-context` policy listener returns `createIfAbsent` + * `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` * (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` * (observed) and does NOT call `next()` — one decision, not a composable * chain. The slot is first-wins: the first non-`next()` decider (registration @@ -102,23 +102,23 @@ declare module 'cordis' { * not layering. `actor` is the opaque tool-execution context, never read here. * @mode waterfall */ - 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** * Single-slot decision: produce the optional version guard for the next * {@link FileSystem.editText}. The tool dispatches this as an unbound * waterfall and supplies a default thunk returning `undefined` (unconditional * edit of the current content — the bare provider; no `stat`). The - * `@deepseek-ai/dsh-file-context` policy listener returns + * `@deepseek-ai/dsh-fs-policy` policy listener returns * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset * or has not observed the target. Does NOT call `next()`: one decision, - * first-wins (see {@link Events.'fs/write-expectation'}). + * first-wins (see {@link Events.'fs/write-intent'}). * @mode waterfall */ - 'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** * Record that an actor observed a target at a version, after a successful * read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a - * synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s + * 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 surfaces as the tool's `isError` result, and cordis `emit` does not * await listener promises — async or fallible audit/telemetry does not @@ -147,7 +147,7 @@ declare module 'cordis' { * binary/NUL rejection, and `FS_NOT_TEXT`. * - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL: * omit it for an unconditional create-or-overwrite (the bare-provider default), - * or supply a {@link FsWriteExpectation} to guard the write. + * or supply a {@link FsWriteIntent} to guard the write. * - {@link editText} verifies `expected.version` BEFORE literal matching (so a * stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ * `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement @@ -188,7 +188,7 @@ export abstract class FileSystem extends Service { * unconditional create-or-overwrite (the bare provider — no version guard, no * read-first requirement). Atomic either way. */ - abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise + abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise /** * Apply a literal edit to an existing UTF-8 text file. When `expected` is diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 258c7a1e8b..15a58ee93b 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -1,12 +1,12 @@ /** * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque - * target/version identities, the metadata `stat` returns, the write-expectation + * target/version identities, the metadata `stat` returns, the write-intent * and outcome shapes, the literal-edit request/outcome, and the typed error * taxonomy. * * These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and * future sandboxed/remote backends) and by the policy layer - * (`@deepseek-ai/dsh-file-context`). They are deliberately a *text-storage* + * (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage* * vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand * back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey` * and `version` are opaque branded tokens, and `displayPath` is the only field a @@ -14,7 +14,7 @@ * * Model-facing concepts (line windows, numbered lines, observed-state) do NOT * live here; they belong to the consumer tool and the policy plugin - * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-file-context`). + * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`). * * @module @deepseek-ai/dsh-fs/types */ @@ -91,7 +91,7 @@ export interface FsInfo { * is expressed by omission, so the write and edit mutations share one symmetric * shape (`expected?`: omit = unconditional, present = guarded). */ -export type FsWriteExpectation = +export type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 7091746dc3..789ed7fdac 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -1,7 +1,7 @@ /** * Tests for the filesystem provider seam itself: registration, duplicate-service * behavior, disposal, and the branded id factories. The provider primitives and - * policy live in `dsh-fs-local` and `dsh-file-context`; this seam owns only the + * policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the * abstract service contract, so a minimal fake backend exercises it. */ @@ -13,7 +13,7 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' @@ -38,7 +38,7 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, _expected?: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index bcc5c5cdf1..bc38a5ee64 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,15 +1,15 @@ # @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`](../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-file-context`](../file-context)) through the `fs/*` event gate; the tool is not method-coupled to it. +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`](../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`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. ```ts ignore-check // 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(FileContext) // @deepseek-ai/dsh-file-context (policy gate) +await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` -`@deepseek-ai/dsh-file-context` 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. +`@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](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) @@ -26,13 +26,13 @@ Field names are snake_case to match Claude Code and existing harness tool schema 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), then `readText`/`streamText`, then builds the line window, then emits a contained `fs/observed`. (1 stat.) -- **write** — `ctx.waterfall('fs/write-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, expectation)`, then `fs/observed`. (0 stat.) -- **edit** — `ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, expectation)`, then `fs/observed`. (0 stat.) +- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) +- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/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-file-context` 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. +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-file-context`'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. +`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. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 5323bbadcf..f92966f515 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-file-context": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index f1eab5e319..efe2e5f53b 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,10 +1,10 @@ /** * The model-facing `edit` tool: update an existing UTF-8 text file by replacing * literal text, requiring a unique match by default. The tool is the executor: - * it dispatches the `fs/edit-expectation` waterfall to obtain the optional + * it dispatches the `fs/edit-intent` waterfall to obtain the optional * version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The * default thunk returns `undefined` (unconditional edit of the current content - * — the bare provider); a policy plugin (`@deepseek-ai/dsh-file-context`) + * — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`) * occupies the single decision slot, returning `{ version: vObserved }` or * throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times * either way; a missing target is reported by the provider as `FS_STALE_VERSION`. @@ -52,7 +52,7 @@ export function applyEditTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:edit', order: 102, - text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default file-context policy requires it), unless you just created or edited it in this session.', + text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.', }) ctx.tools.register(defineTool({ @@ -70,11 +70,11 @@ export function applyEditTool(ctx: Context): void { // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). // No stat — the bare default never manufactures a version basis. - const expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const outcome = await ctx.fs.editText( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, - expectation, + intent, exec.signal, ) // Record the observed version (a no-op when no policy plugin listens). diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 285299e352..e9f384a96c 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -8,15 +8,16 @@ * concerns only — tool names, JSON schemas, argument validation, prompt * sections, read windowing, result formatting. It does NOT inject a policy * service. Instead, on each write/edit it dispatches a single-slot waterfall - * (`fs/write-expectation`/`fs/edit-expectation`) to obtain the OPTIONAL version - * guard, and after every read/write/edit it emits a contained `fs/observed`. A - * policy plugin (`@deepseek-ai/dsh-file-context`, loaded by the default product - * config) occupies the decision slot and listens for `fs/observed` to add - * observed-state + read-before-edit + version-guarded write/edit. With no policy - * plugin the waterfalls fall through to their `undefined` default (the - * unconstrained bare provider) and `fs/observed` is unheard — the tool still - * functions. This package never imports `node:fs`, `node:path`, or an - * `@deepseek-ai/dsh-fs-local` implementation. + * (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and + * after every read/write/edit it emits `fs/observed` with a plain (unguarded) + * `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the + * decision slot and listens for `fs/observed` to add observed-state + + * read-before-edit + version-guarded write/edit; a deployment that loads these + * tools is expected to also load it. With no policy plugin the waterfalls fall + * through to their `undefined` default (the unconstrained bare provider) and + * `fs/observed` is unheard — the tool still functions. This package never + * imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local` + * implementation. * * @module @deepseek-ai/dsh-tool-fs */ diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 31d31424cb..b7e0d43772 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -3,7 +3,7 @@ * line-numbered content with pagination guidance. The tool is the executor — it * stats and reads through `ctx.fs` directly, builds the line window * ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed` - * so a policy plugin (`@deepseek-ai/dsh-file-context`) can record the read. With + * so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With * no policy plugin the emit is simply unheard. This module owns the * model-facing schema, argument validation, and the read I/O; the rendering * (windowing + formatting) lives in `read-render.ts` and the diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 564d99ddd6..ed9143f32a 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,10 +1,10 @@ /** * The model-facing `write` tool: create or fully replace a UTF-8 text file. The - * tool is the executor: it dispatches the `fs/write-expectation` waterfall to + * tool is the executor: it dispatches the `fs/write-intent` waterfall to * obtain the optional version guard, calls `ctx.fs.writeText` directly, and * emits `fs/observed`. The default thunk returns `undefined` (unconditional * create-or-overwrite — the bare provider); a policy plugin - * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot and + * (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO * times either way. * @@ -39,7 +39,7 @@ export function applyWriteTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:write', order: 101, - text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default file-context policy requires it) and prefer edit for targeted changes.', + text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.', }) ctx.tools.register(defineTool({ @@ -54,8 +54,8 @@ export function applyWriteTool(ctx: Context): void { const target = await ctx.fs.resolve(input.filePath) // Single-slot decision: the policy plugin produces createIfAbsent/ // replaceIfVersion; the bare default is undefined (unconditional). No stat. - const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined) - const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal) + const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined) + const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal) // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 087227f790..238909ff98 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -3,7 +3,7 @@ * tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()` * so nothing bypasses the tool registry. Two deployments: * - * - DEFAULT — with the real `dsh-file-context` policy gate plugin: read-before- + * - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before- * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. * - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to * its undefined default, so write/edit are unconditional. This proves the @@ -22,7 +22,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' -import * as FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string @@ -53,14 +53,14 @@ afterEach(async () => { // -------------------------------------------------------------------------- // DEFAULT deployment: the policy gate plugin is loaded. // -------------------------------------------------------------------------- -describe('default deployment (with dsh-file-context)', () => { +describe('default deployment (with dsh-fs-policy)', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-')) ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: dir }) - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) fiber = await ctx.plugin(ToolFs) }) @@ -228,7 +228,7 @@ describe('default deployment (with dsh-file-context)', () => { // -------------------------------------------------------------------------- // BARE deployment: the tool suite WITHOUT the policy gate. // -------------------------------------------------------------------------- -describe('bare provider (no dsh-file-context)', () => { +describe('bare provider (no dsh-fs-policy)', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-')) ctx = new Context() diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 5ca6947354..4a161a272b 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,6 +1,6 @@ /** * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the - * REAL `@deepseek-ai/dsh-file-context` gate plugin (the genuine policy + * REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy * collaborator, per the prefer-the-real-implementation rule) over a fake * `ctx.fs` provider, so they verify schemas, argument validation, result * formatting, FsError→isError propagation, and that each tool dispatches the @@ -19,10 +19,10 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -import * as FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs' import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' @@ -31,8 +31,8 @@ import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' class FakeFs extends FileSystem { files = new Map() rejectWith?: FsError - writeExpectations: (FsWriteExpectation | undefined)[] = [] - editExpectations: ({ version: FsVersion } | undefined)[] = [] + writeIntents: (FsWriteIntent | undefined)[] = [] + editIntents: ({ version: FsVersion } | undefined)[] = [] private throwIfArmed(): void { if (this.rejectWith) throw this.rejectWith @@ -54,16 +54,16 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, expected?: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() - this.writeExpectations.push(expected) + this.writeIntents.push(expected) const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise { this.throwIfArmed() - this.editExpectations.push(expected) + this.editIntents.push(expected) const content = this.files.get(target.targetKey) ?? '' this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } @@ -75,7 +75,7 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) const fs = ctx.fs as FakeFs return { ctx, fs } @@ -122,11 +122,16 @@ describe('registration', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) const fiber = await ctx.plugin(ToolFs) + // Each tool contributes BOTH a schema and a prompt section; disposal must + // withdraw both, not just the schemas. expect(ctx.tools.schemas()).toHaveLength(3) + const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort() + expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) + expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) }) }) @@ -174,7 +179,7 @@ describe('read tool', () => { expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false) const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session }) expect(edited.isError).toBe(false) - expect(fs.editExpectations).toEqual([{ version: 'v1' }]) + expect(fs.editIntents).toEqual([{ version: 'v1' }]) }) it('propagates FS_NOT_FOUND for an absent file', async () => { @@ -257,7 +262,7 @@ describe('write tool', () => { const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: {} }) expect(result.isError).toBe(false) expect(text(result)).toContain('Created file') - expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) + expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }]) }) it('rejects a blank file_path', async () => { diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 7c03431ee4..6af16400c0 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -12,6 +12,6 @@ { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, { "path": "../fs" }, - { "path": "../file-context" } + { "path": "../fs-policy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39fdcbd628..58ea6d9bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,18 +278,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/fs/file-context: - devDependencies: - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../fs - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': @@ -318,20 +306,32 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/fs-policy: + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/tool-fs: devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-file-context': - specifier: workspace:^ - version: link:../file-context '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../fs-policy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 68933a2a13..8d84eff57a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -80,10 +80,9 @@ const LINK_MAP: Record = { FsInfo: 'filesystem.md', FsTarget: 'filesystem.md', FsVersion: 'filesystem.md', - FsWriteExpectation: 'filesystem.md', + FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', - FileContextExec: 'filesystem.md', - FileReadRequest: 'filesystem.md', + FsPolicyExec: 'filesystem.md', FileReadOutcome: 'filesystem.md', } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 6d584bc6c6..9a6fa4cab8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -46,12 +46,12 @@ { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteExpectation", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index a3334bb397..442d187a38 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -30,7 +30,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, - { "path": "./packages/fs/file-context" }, + { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, diff --git a/tsconfig.json b/tsconfig.json index a192e9319e..00a3a21460 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, - { "path": "./packages/fs/file-context" }, + { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" },