State, Append, Journal and - uniquely here - Write, the ONE shared write executor every N > 0 write goes through.
hook-dsh-normalize-file
Hook @ DSH @ Normalize @ File • _The DeepSeek Harness Plugin Family for PlayForm._ The file-content normalizer - a DeepSeek Harness plugin that registers the normalize family's normalize-file TOOL: the read → count → write pipeline over files ALREADY on disk, beyond the tool layer. One agent-chosen file per call: the target's content is read, the family's SIX transforms are applied with a per-character count, and only when N > 0 is the rewritten content written back through the factory's ONE shared write executor. N = 0 writes NOTHING - the no-op no-write rule. The family's first LISTENER-LESS flavor: no llm/stream, no fs/observed - nothing runs without an explicit agent action. This bundle's own tool calls are name-exempt in the stream gate beside edit and raw-write - its arguments carry a FILE PATH, and a normalized dash inside a filename would corrupt the target.
$ pnpm add @playform/hook-dsh-normalize-file The profile wiring for this plugin - the bundles list, the patch entry and the restart - is on the setup page.
Where It Fits
Family position (the @-sentence Hook @ DSH @ Normalize @ File): a tool-child of the plugin-dsh-factory service and the hook-dsh-core machinery; the seventh sibling - the one flavor that works on files already on disk instead of model output:
| Flavor | Layer | Mechanism |
|---|---|---|
| hook-dsh-normalize-dash | model output (stream) | core Dashes class → replacement (default -) |
| hook-dsh-normalize-quotes | model output (stream) | core Quotes MAP → curly → straight |
| hook-dsh-normalize-ellipsis | model output (stream) | core Ellipsis class → ... |
| hook-dsh-normalize-spaces | model output (stream) | core Spaces class → " " |
| hook-dsh-normalize-invisible | model output (stream) | core Invisible class → removed |
| hook-dsh-normalize-fullwidth | model output (stream) | core Fullwidth MAP → full-width → half-width |
| hook-dsh-normalize-file (this bundle) | files already on disk (tool) | all SIX, at write time, through Factory.Write |
The six stream flavors cover only what the model is emitting right now; the raw-write tool's normalize: true covers only content being written. This flavor covers the gap in between: pre-existing files, git-cloned material, script-created files - anything already on disk that the agent did not just write. The hermes heritage is direct: the user's ~/.hermes/agent-hooks/normalize-dashes-for-execute-code.sh swept script-created files after the fact; DSH makes the same rewrite an explicit, visible, opt-in TOOL call instead of a background hook (and normalize-tabs.sh - the repair hook for a repair hook - is the cautionary tale that keeps it that way).
A non-manifest factory consumer: it injects ["pluginFactory", "fs", "tools"] and uses State (cell unwrap + shared Ledger/Enabled mappings + its own field), Append (the ledger), Journal (the P5 storage record) and - uniquely in the family - Write, the ONE shared write executor. The tool's writes carry the call's exec as the actor, so the governance trio's Gate (which pins mutationTools to write/edit/str_replace_editor) never treats them as a trigger - the same protection raw-write already has.
In the DeepSeek Harness
| Seam | What the plugin does there | What you can observe |
|---|---|---|
| The tool layer - the agent's toolset | The plugin registers the normalize-file tool into the agent's toolset: the agent calls it like any built-in tool, one agent-chosen file per call. Nothing runs without that explicit action - no llm/stream, no fs/observed listener of any kind. | Zero background activity: discoverable, visible, opt-in. |
| ctx.fs - the write executor behind the tools (dsh-fs) | Every N > 0 write goes through the factory's ONE shared write executor - the fs/write-intent waterfall, the standing sandbox policy, writeText end to end, and the fs/observed {kind: present, version} emit on the root context with the call's exec as the actor. | Byte-identical with the raw-write path; the observation policy sees it like any tool write. |
| The governance interlock | The write's actor is the call's exec, and normalize-file is never added to any governor's mutationTools pin - so these writes never trigger a chain pass; the tool calls are also name-exempt in the stream gate beside edit and raw-write. | Normalization without re-processing loops, and edit targets that never corrupt. |
| The storage domain | Each N > 0 write journals one normalized record into the shared package_governance v2 domain (path = the target's display path, detail byte-identical to the count line) - best-effort: with no storage facility the record buffers or drops. | A machine-readable history beside the human ledger. |
| The ledger / session | Two lines through the factory's Append: the activation proof from apply() and the count line that follows only a successful N > 0 write. | A no-op writes no line; a failed read or aborted call writes none either. |
The Problem
Files that never passed through a write tool are invisible to every normalization layer DSH has: raw-write's normalize: true is an explicit per-call opt-in for NEW writes, and the six stream flavors only rewrite model output - they never touch disk. Left unnormalized, a pre-existing file keeps every typographic dash, curly quote, ellipsis, unicode space, zero-width character and full-width character it was born with - exactly the characters that break parsers, shells, diffs and byte-exact edit matches downstream.
But the fix cannot be another silent rewriter. Files are rewritten behind the agent only at the price of the edit tool's old_string contract: the agent read bytes X, a background rewriter silently changed them to X', and the next edit fails or half-matches. Hermes learned this the hard way - its normalize-tabs.sh exists to repair the damage its own after-the-fact rewriting caused. The tool is the answer: explicit, visible, discoverable, zero background activity.
How It Works
The transformation is exactly the raw-write normalize: true chain - the core's tables and replacers, applied whole at write time. There is no stream dispatch to gate: the tool registers NO event listener, so the six transforms are applied to the entire file content in one pass, and the count is the ledger's N and the no-op condition in one.
The conflict map, honored by construction. Governance bounded passes: the write goes through Factory.Write and emits fs/observed, but the governors' Gate requires the actor tool name in their mutationTools pin - normalize-file is never added to any such list, so these writes never trigger a chain pass. Edit old_string contract: safe because visible - the diff card shows the before/after in the same turn, and the tool description says to re-read before editing. Raw-write read-before-write: tool calls are serialized and Factory.Write's before is read at write time, so a prior normalize-file in the same turn is already reflected. No race.
The Config
| Field | Type | Default | Volatile | Meaning |
|---|---|---|---|---|
| log | boolean | true | yes | write the durable ledger file |
| logFile | string | ~/.dsh/hook-dsh-normalize-file.log | yes | the normalize-file ledger (separate from the family's logs) |
| replacement | string | - | yes | the dash step's replacement (the transform's only knob) |
There are no stream flags (normalizeReasoning, normalizeToolArguments) and no fs/observed fields ( updateCooldownMs, mutationTools,policyFile, exclude): the minimal shared: false block plus the two knobs is the whole config surface, because the tool registers no listener of any kind. Volatile cells commit without remounting the plugin. The tool is called by the agent like any built-in tool:
In Action
Before - the file as it sits on disk
Every em dash (U+2014) becomes the ASCII hyphen-minus, the curly quotes become straight ones, the ellipsis becomes three periods; five characters replaced, so N = 5 and the write happens. The result carries the outcome and the diff card shows the before/after in the same turn; the ledger gets the count line shown below. A file with nothing to replace is a no-op: changed: false, the file stays byte-identical, no write, no count line, no journal record. A missing, binary or undecodable target is an error result with no write. Because the file's bytes change under you, re-read before editing - the edit tool's old_string must match the new content.
The Ledger
Two lines, both written through the factory's Append (the hook-dsh-normalize-file: prefix is the logger's <State.Module>:; the durable file line is [<ISO>] <message>):
The activation line is written by apply(); the count line follows only a successful N > 0 write - a no-op writes no line, and a failed read or an aborted call writes none either. Each N > 0 write also journals one normalized record into the shared package_governance v2 domain (event normalized, path = the target's display path, detail byte-identical to the count line), best-effort: with no storage facility the record buffers or drops and the human ledger stays the complete record.
Related plugins
The stream sibling and the registrar of the raw-write tool - whose normalize:true chain this tool applies whole.
The six transform tables and the generic Replace/ReplaceMap replacers this tool chains.
License: MIT.