@flotiarenor/dsh-tool-text-editor 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,45 +2,42 @@
2
2
 
3
3
  [中文](README.zh.md) | English
4
4
 
5
- Model-facing tools for [DeepSeek Harness](https://github.com/deepseek-ai) (dsh) that edit text
6
- files **byte-faithfully**: `edit_text` and `write_text`.
5
+ Model-facing tools for [DeepSeek Harness](https://github.com/deepseek-ai) (dsh) that edit text files
6
+ **byte-faithfully**: `edit_text` and `write_text`. They fix what the built-in `write` / `edit`
7
+ (`@deepseek-ai/dsh-fs-local`) cannot do — both defects below were verified against a real installation
8
+ (§ "Versus the built-ins"):
7
9
 
8
- They exist because the built-in tools lose Windows file conventions:
9
-
10
- | Case (file is UTF-8 **BOM + CRLF**) | built-in `edit` | built-in `write` | this plugin |
11
- |---|---|---|---|
12
- | change one line | CRLF kept / **BOM lost** | — | BOM + CRLF kept |
13
- | full overwrite | — | **BOM lost + CRLF flattened to LF** | BOM + CRLF kept |
14
-
15
- `@deepseek-ai/dsh-fs-local` has no BOM handling at all (Node's `TextDecoder` strips a leading BOM
16
- byte by default) and `writeText` does not restore a file's line-ending style.
10
+ | Built-in behaviour | Cause | This plugin |
11
+ |---|---|---|
12
+ | **UTF-8 BOM lost** on any edit or overwrite | the implementation has no BOM handling; Node's `TextDecoder` strips a leading BOM by default | BOM preserved |
13
+ | **Line-ending style not restored** on a full overwrite | `writeText` writes `content` verbatim, so LF content turns a CRLF file into an LF file | line endings follow the file |
14
+ | **`FS_EDIT_NOT_FOUND`** when `old_string` differs by a space | the built-in `edit` matches literally, with no fallback | exact → relaxed → nearest candidates (ambiguity refuses to write) |
17
15
 
18
- On top of fidelity: **unified diffs** (with a `dry_run` preview), **automatic backups**, an **edit
19
- ledger**, **`grep` / `lines` anchors** so old text never has to be copied by hand, **ambiguity
20
- refusal**, and **near-miss candidates** when an anchor does not match.
16
+ Also **`grep` / `lines` anchors** (old text is never copied by hand) and **near-miss candidates**;
17
+ "Return value" documents the result value and the model-facing text.
21
18
 
22
- The canonical return value of both tools, the composition of the model-facing text, and the UI card
23
- projection are documented under "Return value".
19
+ A call leaves **no side artifacts**: the only thing written is the target file.
24
20
 
25
21
  ## Implementation and requirements
26
22
 
27
- The implementation is **in-process Node** (`lib/core.mjs`): `node:` builtins only, no subprocess, no
28
- build step, no third-party package.
23
+ **In-process Node** (`lib/core.mjs`): `node:` builtins only, no subprocess, no build step, no third-party
24
+ package.
29
25
 
30
26
  | Requirement | Notes |
31
27
  |---|---|
32
- | Node | **The only dependency** — no interpreter, no external runtime, no process-startup cost per call. |
28
+ | Node | **The only dependency**: no interpreter, no external runtime, no process-startup cost per call. |
33
29
 
34
- The package installs nothing of its own. Its single `peerDependencies` entry, `@deepseek-ai/dsh-tools`,
35
- is the host contract — "needs this dsh or newer" — and resolves from the dsh installation rather than
36
- being installed beside the plugin.
30
+ The package installs nothing. Its one `peerDependencies` entry, `@deepseek-ai/dsh-tools`, is the host
31
+ contract ("needs this dsh, and not a later major line") and resolves from the dsh installation, not beside
32
+ the plugin. The range carries two clauses, `>=0.1.0-rc.6 || >=0.1.5-rc.2`, because npm admits a prerelease
33
+ only when the range has a comparator sharing its `major.minor.patch` tuple; each verified dsh line therefore
34
+ needs its own clause, or a prerelease install reads as an unmet peer.
37
35
 
38
36
  ## Install
39
37
 
40
38
  ### Option 1 — preset (recommended, tightly scoped)
41
39
 
42
- Only sessions that select this preset see the two tools. Run this from the root of a clone of this
43
- repository:
40
+ Only sessions selecting this preset see the two tools. Run from the root of a clone of this repository:
44
41
 
45
42
  ```powershell
46
43
  node scripts/install-preset.mjs
@@ -48,13 +45,13 @@ node scripts/install-preset.mjs
48
45
  # / --force / --dry-run / --from <agent.cordis.yml path>
49
46
  ```
50
47
 
51
- Then restart `dsh web` and start a new session on preset `texteditor`; a preset is a session-creation
52
- fact, so a running session cannot switch to it.
48
+ Restart `dsh web` and start a new session on preset `texteditor`: a preset is a session-creation fact, so a
49
+ running session cannot switch to it.
53
50
 
54
51
  ### Option 2 — install into the profile (available to every session)
55
52
 
56
- `dsh plugin add` accepts several kinds of source, and every one of them works here: the package is
57
- prebuilt, dependency-free ESM, so there is no `prepare`/`build` step to authorize or run.
53
+ `dsh plugin add` accepts several kinds of source, all working here: the package is prebuilt, dependency-free
54
+ ESM, with no `prepare` / `build` step to authorize or run.
58
55
 
59
56
  | Source | Command |
60
57
  |---|---|
@@ -69,85 +66,138 @@ Verify the layer without starting anything, then restart:
69
66
  dsh --profile web --dump-config # look for the "# == @flotiarenor/dsh-tool-text-editor" layer
70
67
  ```
71
68
 
72
- The tool names do not collide with the built-ins, so a host-plane insert is safe. The trade-off is
73
- that both tools (and their guidance section) show up in **every** session. Uninstall with
74
- `dsh plugin --profile web remove @flotiarenor/dsh-tool-text-editor`.
69
+ The tool names do not collide with the built-ins, so a host-plane insert is safe; the cost is both tools and
70
+ their guidance section in **every** session. Uninstall with `dsh plugin --profile web remove @flotiarenor/dsh-tool-text-editor`.
71
+ Both installs may coexist, the preset layer shadowing the host layer with an identical definition.
72
+
73
+ ## Masking the built-in `write` / `edit`
74
+
75
+ The editor row **adds** `edit_text` / `write_text` and leaves the built-ins alone. Removing them is a second,
76
+ separate row — `lib/mask.mjs` — that the installer only injects on request:
77
+
78
+ ```powershell
79
+ node scripts/install-preset.mjs --mask-native # also injects the tool-native-edit-mask row (and guidance: short)
80
+ ```
81
+
82
+ Two rows are forced, not a matter of taste: `tools.restrict()` is callable only from a **scoped context**
83
+ (`agent.ctx`), and a preset row's own context *is* the standing scope where the natives are registered, so a row
84
+ cannot hide tools from itself. Masking is therefore a fact about the composition, and only a second row can state
85
+ it.
86
+
87
+ It removes **2362 B per request** (`node tools/measure-context.mjs --vs-native`): two schemas (1754 B: `write`
88
+ 728 + `edit` 1026, sandbox escalation fields included) plus two guidance sections (608 B: 220 + 388);
89
+ `guidance: short` on the editor row removes another 81 B (241 -> 160), for **2443 B per request** (~600 tokens).
90
+
91
+ | Mechanism | How it works |
92
+ | --- | --- |
93
+ | Guard, armed in `apply()` on the row's own scope layer | Refuses the first native call of any agent, naming `edit_text` / `write_text`, and narrows that agent in the same call, so the next request's table is already clean. Being on the layer rather than on an event, its coverage does not depend on when the agent was created |
94
+ | Tool catalog and execution | `agent.ctx.tools.restrict({ deny: ['write','edit'] })` — the named tools leave the catalog **and** become uncallable (naming one yields `UNKNOWN_TOOL`) |
95
+ | Prompt | `agent.ctx.systemPrompt.section({ name: 'tool:write', text: '' })` shadows the two guidance sections `dsh-tool-fs` registers. A redundant safety net: since 0.1.5-rc.2 those sections are visibility-gated and disappear on their own |
96
+ | Membership | The guard is asked who it belongs to, because a preset can be swapped under a live agent. The row calls `ctx.tools.guardReason()` with an execution object **it minted itself**, so the answer is object identity, not a name: a real dispatch always mints a fresh object and can never be mistaken for the probe, and a foreign guard that refuses everything cannot answer for this row |
97
+ | Leaving, and unloading | `restrict` and the shadowing sections sit on the **agent's own layer**, so they outlive the preset; each is kept as a disposer and lifted when the agent re-links elsewhere — otherwise that agent is left with neither the native names nor this plugin's tools, i.e. no way to write at all. Unloading the row does the same for every agent it masked |
98
+ | Host-plane install | A row mounted on the host plane (profile layer) has no scope, so its guard lands in the global layer, where narrowing would hit presets that never mounted this plugin. The same probe detects exactly that and the row degrades to guard-only, with one warning. No config key expresses this — the mount shape decides |
99
+
100
+ Membership is deliberately three-state (`member` / `outsider` / `unknown`) and a mask is lifted only on a
101
+ definite `outsider`: an unanswerable probe means "leave that agent alone", because reading it as `outsider` would
102
+ silently undo a working restriction. A probe that cannot answer at all is reported once, loudly.
75
103
 
76
- Both installs may coexist: the preset layer shadows the host layer with an identical definition.
104
+ ### Observing the built-ins once masked: four ways
105
+
106
+ | Way | How |
107
+ | --- | --- |
108
+ | **Another composition (another preset)** | Masking is a composition fact, not a global switch: an agent of another composition keeps both tools visible and callable, the control group `tools/probe-mask.mjs` and `tools/repro-mask.mjs` assert; a **sibling agent of the same preset** is masked too, deliberately |
109
+ | **The probe** | `node tools/probe-mask.mjs` mounts the mask on the real dsh packages and registry (see "Self-test and gates"); 15 checks, exit code 2 without an installed dsh |
110
+ | **The built-ins driven directly** | `tools/measure-context.mjs --vs-native` calls `apply()` on the real `dsh-tool-fs` in-process and measures its schemas and sections; no agent is involved, so no mask can reach it |
111
+ | **A temporary lift** | Give the `tool-native-edit-mask` row `disabled: true` |
112
+
113
+ ### Mask row configuration
114
+
115
+ The row has no Config schema either: `config:` is passed through as-is.
116
+
117
+ | Key | Default | Meaning |
118
+ |---|---|---|
119
+ | `deny` | `['write','edit']` | the names to narrow; only names that agent actually sees are named |
120
+ | `sections` | `['tool:write','tool:edit']` | the guidance sections to shadow with an empty section; `[]` turns the shadowing off |
121
+
122
+ ### Boundaries
123
+
124
+ | Boundary | Meaning |
125
+ | --- | --- |
126
+ | **Not an authority boundary** | Visibility composition as DSH defines it: the built-ins **stay registered** and a shell command can still write files |
127
+ | **A composition fact, not a global switch** | The row touches only the agents that joined its composition: the sibling agent of the same preset included, an agent of another preset not at all. A host-plane install is the one exception, and it never narrows |
128
+ | **Names are configuration** | `deny` / `sections` are config, so a rename or split of `tool-fs` is a config edit |
129
+ | **Only visible names are named** | A preset without `tool-fs` gets no restriction at all (never an unknown-name error), and the guard asks visibility per call, leaving a name that exists elsewhere but not for the caller alone |
77
130
 
78
131
  ## Tools
79
132
 
80
133
  ### `edit_text` — targeted replacement
81
134
 
82
- `file_path` and `new_text` are required; give **exactly one** anchor: `old_text` (literal, copied from
83
- `read`), `grep` (regex; the matched line/block including its trailing newline), or `lines` (e.g.
84
- `"263:270"`). `mode` is `replace` (default) / `after` / `before` / `append` / `prepend`; also `count`
85
- (require exactly N occurrences and replace all), `nth` (k-th occurrence), `strict`, `diff` (`auto` /
86
- `full` / `none`), `dry_run`, `note`. `count` and `nth` are mutually exclusive.
135
+ | Aspect | Rule |
136
+ | --- | --- |
137
+ | Required | `file_path` and `new_text` |
138
+ | Anchor, exactly one | `old_text` (literal, copied from `read`) / `grep` (regex; the matched line/block including its trailing newline) / `lines` (e.g. `"263:270"`, also including the trailing newline) |
139
+ | `mode` | `replace` (default) / `after` / `before` / `append` / `prepend` |
140
+ | `count` | The expected number of hits: occurrences of the literal for `old_text` (all of them replaced), regex hits for `grep`, covered lines for `lines`. Any mismatch refuses to write |
141
+ | Trailing newline | Both anchor kinds span the line block **with** its trailing newline, so end `new_text` with a newline too; otherwise the replacement joins the following line and the file loses a line (the `+1/-2` stat reports it) |
142
+ | Matching | Exact → relaxed (trailing whitespace, line-block similarity) → nearest candidates on a miss; a match hitting several places without `count` refuses to write, and a relaxed hit adds one `[warn]` line to the result |
143
+ | No k-th hit | `count` is the only disambiguation knob, and it means *confirmation*, not *selection*. To change one occurrence among several, make the anchor unique — a longer `old_text`, or `lines` / `grep` |
87
144
 
88
145
  ### `write_text` — create or fully replace a file
89
146
 
90
- `file_path` + `content` (plus the same `diff` / `dry_run` / `note`); creation needs no flag, an
91
- overwrite is backed up first, and a brand-new file follows the **majority** line-ending style of its
92
- siblings (same extension first) with no BOM by default.
93
-
94
- Both **write by default** (like the built-ins); pass `dry_run: true` to preview.
147
+ `file_path` + `content`; creation needs no flag (missing parent directories are created) and a brand-new file
148
+ follows the **majority** line-ending style of its siblings (same extension first) with no BOM by default.
149
+ **`content: ''` against a missing target creates a zero-byte file** (stat line `write +0/-0`), while writing
150
+ empty content over an already-empty file is still refused as "no change": a real no-op, not a creation. Both
151
+ tools **write**; neither has a preview mode.
95
152
 
96
153
  ### Return value
97
154
 
98
- Both tools return the same canonical value (`OUTPUT_SCHEMA`). Field contents and destinations:
155
+ Both tools return the same canonical value (`OUTPUT_SCHEMA`), and it is the whole result: there is no
156
+ presentation channel and no side record. `render` reads `ok`, `brief` and `stderr`; `path` is carried for
157
+ the caller but never rendered.
99
158
 
100
159
  | Field | Content | Destination |
101
160
  |---|---|---|
102
- | `path` | the target path as supplied by the caller, echoed back | — |
103
- | `ok` / `wrote` / `dryRun` | outcome flags | — |
104
- | `brief` | warning lines plus one stat line, e.g. `replace@60 +1/-1` | model context |
105
- | `diff` | a unified diff of the changed lines only (`@@` hunk headers, 0 context lines, no `---` / `+++` file headers), limited by `maxDiffLines` | model context |
106
- | `stdout` | the full human record: path header, complete diff with context lines, backup filename | UI / logs / triage |
161
+ | `path` | the target path as supplied by the caller | canonical value only — **not** rendered |
162
+ | `ok` | whether the write succeeded | model context (`FAIL` vs `WROTE`) |
163
+ | `brief` | one stat line (e.g. `replace@17 +1/-1`) plus any warning lines | model context |
107
164
  | `stderr` | failure reason (non-empty on failure) | model context |
108
165
 
109
- The model-facing text consists of `brief` and `diff`, with the path appearing once in the leading
110
- line; the `diff` body carries no `---` / `+++` file headers, so the path never recurs inside it. The
111
- complete diff is additionally projected by `output.presentationMeta` into a list of
112
- `{ path, oldText, newText }`, the same card vocabulary the built-in `edit` / `write` tools use, and
113
- handed to the Web UI by `presentResult`; that metadata is persisted with `tool/result` and never
114
- enters the model context.
166
+ The model-facing text has exactly two shapes:
115
167
 
116
- On failure neither `brief` nor `diff` is returned: the model-facing text is `FAIL` plus the target
117
- path, followed by the complete failure reason. That reason is produced by the core and usually
118
- contains the workspace-relative path once more (a failure favours a complete reason).
168
+ ```
169
+ WROTE # success: stat line + warnings
170
+ replace@17 +1/-1
171
+ FAIL # failure: the complete reason (it decides the next call)
172
+ <reason>
173
+ ```
119
174
 
120
- The `diff` argument selects the detail level of the `diff` field:
175
+ **A successful call echoes neither the change nor the path.** Results are appended to the session history, so an
176
+ echo accumulates per call while the caller has just sent `new_text`; `replace@17 +1/-1` already reports which
177
+ lines changed and by how much, and `read` is one call away. The path is bound to its call (`tool/result` carries
178
+ `source.callId`) and the caller's `file_path` is in the same turn's history, so echoing it adds nothing — and it
179
+ costs: across 86 results in 79 real session logs, one success averaged 116 B of which the `WROTE <path>` line was
180
+ 55.6 B (48%), against 66 B path-free (−43%). It survives where it carries meaning: in a failure reason that has
181
+ to name the file, and in the GUI's own rendering of the call arguments.
121
182
 
122
- | Value | Behavior |
123
- |---|---|
124
- | `auto` | default. The body is returned when it fits within `maxDiffLines`; otherwise it is omitted with a one-line note |
125
- | `full` | the body is always returned; it is truncated with a one-line note when it exceeds `maxDiffLines` |
126
- | `none` | no body is returned |
183
+ Failures are reported as an errno plus one reason (`ENOENT`, `ENOTDIR`, `EISDIR`, `EACCES`, …); the absolute
184
+ paths and the internal temp filename (`.<name>.<pid><ts>.tmp`) of the raw Node message stay out of the model
185
+ context.
127
186
 
128
- The body always uses 0 context lines; the `context` setting affects `stdout` and the UI card only.
129
- `maxDiffLines` bounds the bytes returned to the model context by a single call: tool results are
130
- appended to the session history and are not prefix-cached, so without a bound a full-file rewrite
131
- produces a return of the same order as the content just sent (a measured ~1.0x amplification).
187
+ The one thing the built-ins offer and this plugin does not is a diff body in the result — the built-in `write`
188
+ returns `before` / `after` so the host can draw a card, at 128 B per call. Here the host draws from the call
189
+ arguments it already has.
132
190
 
133
191
  ## Deliberate limitations
134
192
 
135
- These are design choices, not defects to be fixed; check them against your use case before relying on
136
- the tools.
137
-
138
- - **Writes bypass `ctx.fs`.** The file is written by the plugin itself, so the fs-observation policy
139
- (read-before-write, version freshness), the sandbox, `sandbox_permissions` escalation and Windows
140
- DACL preservation are all skipped. The atomic write is implemented by the plugin (same-directory
141
- temp file + fsync + rename), and the diff card is projected by the plugin's `presentationMeta`
142
- whereas the built-ins use the `before` / `after` returned by `ctx.fs`.
143
- - **Line anchors are not content-verified.** `lines` and `before` / `after <line>` locate text by line
144
- number alone: a wrong number does not fail, it edits somewhere else. When the anchor has to be
145
- verifiable, use `old_text` or `grep`.
146
- - **Per-target serialization is per process.** An in-process queue per target plus an atomic write
147
- keeps parallel tool calls from overwriting each other, but another dsh instance, an editor or any
148
- other process writing the same file still can, and external changes are not detected.
149
- - **UTF-8 text only.** Files containing NUL bytes (binary) or invalid UTF-8 are refused, as are paths
150
- inside `.git/` or `.dsh/` and paths outside the workspace.
193
+ | Limitation | Detail |
194
+ | --- | --- |
195
+ | **Writes bypass `ctx.fs`** | The plugin writes the file itself, so the fs-observation policy (read-before-write, version freshness), the sandbox, `sandbox_permissions` escalation and Windows DACL preservation are all skipped; the atomic write is its own (same-directory temp file + fsync + rename). Nothing else enforces anything on this path, so the session file policy is mirrored for the one mode that forbids writing: `read-only` refuses both tools before any I/O, naming the session policy rather than the path. `sandboxPolicy` is consumed opportunistically (`ctx.get`), so a deployment without it, or a resolver that throws, keeps writing instead of bricking |
196
+ | **Only `read-only` is mirrored, and no path is restricted** | Under `workspace-write` and `danger-full-access` both tools write **any** path: outside the workspace, inside `.dsh/` or `.git/`, and through a junction / symlink pointing out of it. The plugin is **not** a security boundary: to restrict where writes land, use the session file policy, the sandbox and `sandbox_permissions` |
197
+ | **Line anchors are not content-verified** | `lines` and `before` / `after <line>` locate text by line number alone: a wrong number does not fail, it edits somewhere else. Where the anchor must be verifiable, use `old_text` or `grep` |
198
+ | **Per-target serialization is per process** | An in-process queue per target plus an atomic write keeps parallel tool calls from overwriting each other, but another dsh instance, an editor or any other process writing the same file still can, and external changes are not detected |
199
+ | **UTF-8 text only** | Files containing NUL bytes (binary) or invalid UTF-8 are refused; a file marked read-only by the OS is refused too (the atomic rename fails with `EPERM`) |
200
+ | **Creating a file fills in missing parent directories** | A missing `write_text` target gets its parents created (`mkdir -p`, as the built-in `write` does), with no extra output |
151
201
 
152
202
  ## Configuration
153
203
 
@@ -155,61 +205,85 @@ There is no Config schema: the preset row's `config:` mapping is passed through
155
205
 
156
206
  | Key | Default | Meaning |
157
207
  |---|---|---|
158
- | `backup` | `true` | copy the previous content into `artifactsDir/backups` before writing |
159
- | `ledger` | `true` | append a JSONL record to `artifactsDir/edits.log` |
160
- | `artifactsDir` | `<workspace>/.dsh` | where backups and the ledger live |
161
208
  | `newFileBom` | `false` | write a UTF-8 BOM when creating a new file |
162
- | `context` | `3` | context lines in the diff of `stdout` and the UI card (the model-facing body always uses 0) |
163
- | `diff` | `'auto'` | default policy for the `diff` field (`auto` / `full` / `none`); a per-call `diff` argument takes precedence |
164
- | `maxDiffLines` | `30` | line limit for the `diff` field: `auto` omits the body when exceeded, `full` truncates at it |
209
+ | `guidance` | `'full'` | three modes: `full` (names the built-ins) / `short` (for when the mask hides them) / `false` (register no section) |
165
210
  | `root` | `process.cwd()` | fallback workspace when a call has no agent session |
166
211
 
167
212
  `DSH_TEXT_EDITOR_EOL` (`lf` \| `crlf`) overrides the line-ending inference for **new** files.
168
213
 
214
+ ## Versus the built-ins
215
+
216
+ Measured against a real installation (`dsh 0.1.5-rc.2`, `lib/editor.mjs` vs `dsh-tool-fs`), each row
217
+ writing the same fixture twice:
218
+
219
+ | Fixture | Built-in | This plugin |
220
+ |---|---|---|
221
+ | CRLF file, `write` with CRLF content | 3 CRLF in, 3 out (content is written verbatim) | same |
222
+ | CRLF file, `write` with LF content | **3 CRLF in, 0 out** — the file silently becomes LF | CRLF kept: the file's own style wins |
223
+ | BOM + CRLF file, either tool | **BOM gone** | BOM kept |
224
+ | Anchor missing a trailing space | `FS_EDIT_NOT_FOUND`, no fallback (a re-read is the only recovery) | relaxed line-block match, one `[warn]` line |
225
+ | Literal appearing twice | refused (`FS_AMBIGUOUS_EDIT`) unless `replace_all: true` | refused unless `count` declares it |
226
+ | Success result text | 128 B, echoing `before` / `after` for the GUI | 17–21 B, echoing nothing |
227
+
228
+ So the honest accounting is: **one correctness fix (the BOM) and one behaviour fix (line-ending style), plus
229
+ cheaper results.** The rest is convenience — anchors that name a position, and a fallback for near-miss anchors.
230
+ If you never touch BOM files, the only thing you gain is tokens, and you pay for it with two competing write
231
+ tools in the model's catalog.
232
+
169
233
  ## Self-test and gates
170
234
 
171
235
  ```powershell
172
236
  # run from the root of a clone of this repository
173
- node tools/selftest.mjs # 92/92 on Windows + Node 24
174
- node tools/check-license.mjs # license / dependency / Node-only gate
175
- node tools/gen-schema.mjs # embedded schemas still match the DSL
237
+ node tools/selftest.mjs # 148/148 on Windows + Node 24
238
+ node tools/check-license.mjs # 30/30 license / dependency / Node-only gate
239
+ node tools/gen-schema.mjs # embedded schemas still match the DSL
240
+ node tools/measure-context.mjs # per-scenario model-visible bytes
241
+ node tools/audit-session.mjs # reconcile against real session logs (text-shape check)
242
+ node tools/audit-session.mjs --tools # the real tool table of every request/header (mask acceptance)
243
+ node tools/probe-mask.mjs # the mask on real dsh packages: registry semantics (15 checks, exit 2 without them)
244
+ node tools/repro-mask.mjs # the mask on real presets + agents: composition timing (23 checks, exit 2 without them)
176
245
  ```
177
246
 
178
- These three live in the repository only: `tools/` is deliberately outside the `files` whitelist, so
179
- the published package is just the plugin, its preset installer, the docs and the license.
247
+ `npm test` chains the licence gate, the self-test and `measure-context --cap 2048`: no single call may put
248
+ more than 2 KB of model-visible text into the context. The current worst scenario is the 1.6 KB ambiguity
249
+ hint; a successful call is always two lines, 17–21 B.
180
250
 
181
- `tools/selftest.mjs` covers BOM/EOL fidelity, `dry_run`, all four anchor kinds, `count`, ambiguity
182
- refusal, usage errors, binary/invalid-UTF-8 refusal, `.dsh/` and outside-workspace guards, majority
183
- EOL inference, multi-hunk diffs, end-of-file newline changes and concurrent writes — **plus a
184
- plugin-layer suite** that drives `apply()` with a fake context and asserts tool registration, the
185
- guidance section, that every returned value satisfies `OUTPUT_SCHEMA`, the `render()` text, and the
186
- config plumbing (`root` / `backup` / `ledger` / `newFileBom`) — **and a return-value suite**: a
187
- full-file rewrite must not echo the content back, a small edit must still report the changed lines,
188
- the three `diff` values must hold their documented boundaries, the path must appear once, and the
189
- complete diff must be projected only through `presentationMeta`.
251
+ These live in the repository only: `tools/` is deliberately outside the `files` whitelist, so the published
252
+ package is just the plugin, its preset installer, the docs and the license. Each script's own header documents
253
+ what it asserts and why; what follows is the shape of each.
190
254
 
191
- `tools/gen-schema.mjs` needs an installed `@deepseek-ai/dsh-tools`: it looks for one under the dsh
192
- profile's `node_modules` and under the npm global prefix, and `DSH_TOOLS_ENTRY` overrides that lookup.
193
- It exits 2 when it cannot find one.
255
+ | Tool | Behaviour |
256
+ | --- | --- |
257
+ | `tools/selftest.mjs` | The end-to-end suite, needing no dsh: BOM/EOL fidelity, all four anchor kinds, `count`, ambiguity refusal, relaxed matching (the similarity threshold pinned on both sides), usage errors, binary/invalid-UTF-8 refusal, majority EOL inference, concurrent writes, parent-directory creation, errno-only failure text — plus a plugin layer (`apply()` on a fake context: registration, the guidance section, every value satisfying `OUTPUT_SCHEMA`, the literal `render()` text, config plumbing, no `.dsh/` left behind), a return-value layer, a policy layer (`read-only` refusals), a mask layer (a fake world for the guard, the membership probe, the sweep, disposal and the unload hook) and a guidance layer. 148 checks |
258
+ | `tools/probe-mask.mjs` | Mask registry semantics on the real packages and the real `dsh-agent` registry, mounted on a real scoped context: a masked agent loses `write` / `edit` and naming one yields `UNKNOWN_TOOL`, another composition keeps both, the standing scope still has them registered, an agent with no creation event is still blocked by the apply-time guard, and a host-plane row degrades to guard-only. 15 checks; exit 2 without a dsh installation |
259
+ | `tools/repro-mask.mjs` | Mask composition timing on the real `dsh-agent-presets` service: created after the mount, re-linked after creation, first bind, switched away, and a child agent via `composeFrom`, with a foreign-guard host and another composition as controls. 23 checks; the regression test for a timing bug that shipped once (4/7 before the fix), exit 2 without the packages |
260
+
261
+ ### Measuring context cost
262
+
263
+ | Tool | Behaviour |
264
+ | --- | --- |
265
+ | `tools/measure-context.mjs` | Drives `apply()` on a simulated context through the real `execute()` → `render()` path, printing input bytes, model-visible bytes, ratio and line count per scenario. `--cap N` exits 1 when a scenario exceeds N bytes; `--static` prints the per-request overhead; `--vs-native` adds the same figures for the host's `write` / `edit` (SKIP without a dsh installation). `--static --vs-native` is the source of the masking figures above |
266
+ | `tools/audit-session.mjs` | Reconciles real session logs (`<DSH_HOME>/sessions/`, multi-frame zstd) per call: whether each result matches one of the two documented shapes (`WROTE` + a stat line, or `FAIL` + a reason), whether a success repeats the call's own `file_path`, and whether a result exceeds `--cap` (1024 B by default). Results from before this shape landed are reported separately as legacy. `--tools` switches to the tool-table view described above |
267
+ | `tools/gen-schema.mjs` | Checks that the embedded schemas still match the author DSL in the same file. Needs an installed `@deepseek-ai/dsh-tools` (`DSH_TOOLS_ENTRY` overrides the lookup); exit 2 without one |
194
268
 
195
269
  ## Layout
196
270
 
197
271
  ```
198
- lib/core.mjs # the core: BOM/EOL, anchors, matching, diff, backups, ledger, atomic write, per-target lock
199
- lib/editor.mjs # the plugin: schemas, validation, tool registration (zero-dep ESM, no build)
272
+ lib/core.mjs # core: BOM/EOL, anchors, matching, atomic write, per-target lock
273
+ lib/editor.mjs # plugin: schemas, validation, registration, read-only mirror (zero-dep ESM)
274
+ lib/mask.mjs # optional row: mask the built-in write / edit per agent (guard + restrict)
200
275
  preset/preset.yml # preset name/description, as dsh lists it
201
- scripts/install-preset.mjs # derives the user preset from the local dsh installation
276
+ scripts/install-preset.mjs # derives the user preset from the local dsh installation (--mask-native adds the mask)
202
277
  cordis.patch.yml # host-plane bundle patch
203
- tools/selftest.mjs # end-to-end self-test (core + plugin layer)
278
+ tools/selftest.mjs # end-to-end self-test (core + plugin + result text + policy + mask + guidance)
279
+ tools/probe-mask.mjs # mask registry semantics on real dsh packages + registry (exit 2 without them)
280
+ tools/repro-mask.mjs # mask composition timing on real presets + agents (exit 2 without them)
204
281
  tools/check-license.mjs # license / dependency / Node-only hygiene gate
205
282
  tools/gen-schema.mjs # authoritative source and checker for the embedded JSON Schemas
283
+ tools/measure-context.mjs # per-scenario model-visible bytes + the native-tool comparison
284
+ tools/audit-session.mjs # real session logs: text-shape reconciliation + the --tools tool-table view
206
285
  ```
207
286
 
208
- Backups and the ledger use fixed, documented names and fields: one file per edit under
209
- `.dsh/backups/`, named `<flattened-absolute-path>@<timestamp>`, and one JSON object per line in
210
- `.dsh/edits.log` (`time`, `id`, `tool`, `file`, `abspath`, `action`, `kinds`, `line_start`,
211
- `line_end`, `added`, `removed`, `bom`, `eol`, `backup`, `summary`).
212
-
213
287
  ## License
214
288
 
215
289
  **Apache-2.0** — see [LICENSE](LICENSE). Copyright 2026 Flotiarenor. The package has **no runtime
@@ -217,6 +291,6 @@ dependencies**, so it carries no third-party license obligations of its own. Thr
217
291
 
218
292
  - The embedded JSON Schemas in `lib/editor.mjs` are generated *output* of the `@deepseek-ai/dsh-tools`
219
293
  converter (MIT, Copyright (c) 2026 DeepSeek) via `tools/gen-schema.mjs`.
220
- - The preset composition is **not** part of this package: `scripts/install-preset.mjs` reads the one
221
- shipped with the user's own dsh installation at install time.
294
+ - The preset composition is **not** part of this package: `scripts/install-preset.mjs` reads the one shipped
295
+ with the user's own dsh installation at install time.
222
296
  - Source files carry an `SPDX-License-Identifier` header, so the license is machine-readable per file.