PLUGIN DETAIL FLAVOR: FILE LISTENER-LESS TOOL

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.

CLI INSTALL COPIED
$ pnpm add @playform/hook-dsh-normalize-file
Namespace: @playform/hook-dsh-normalize-file Release: v0.0.1
Archetype: Tool Listeners: NONE Injects: pluginFactory, fs, tools

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: 7TH SIBLING - FILES ON DISK

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:

FlavorLayerMechanism
hook-dsh-normalize-dashmodel output (stream)core Dashes class → replacement (default -)
hook-dsh-normalize-quotesmodel output (stream)core Quotes MAP → curly → straight
hook-dsh-normalize-ellipsismodel output (stream)core Ellipsis class → ...
hook-dsh-normalize-spacesmodel output (stream)core Spaces class → " "
hook-dsh-normalize-invisiblemodel output (stream)core Invisible class → removed
hook-dsh-normalize-fullwidthmodel 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

A TOOL IN THE AGENT'S TOOLSET
SeamWhat the plugin does thereWhat you can observe
The tool layer - the agent's toolsetThe 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 interlockThe 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 domainEach 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 / sessionTwo 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

WHY THE FILE TOOL EXISTS

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

READ → COUNT → WRITE
agent calls normalize-file { file_path } | v ctx.fs.resolve the model-supplied path becomes a | stable target (caller-side, like raw-write) v ctx.fs.readText(target, signal) the shared read path - a missing file, | a directory, a binary/undecodable target, | a permission failure or an abort rejects | here -> error result, NO write v Rewrite(content, replacement) the counting six-fold chain, in order: | 1. Dashes class -> replacement ("-") | 2. Quotes MAP -> curly -> straight | 3. Ellipsis class -> "..." | 4. Spaces class -> " " | 5. Invisible class -> "" (removed) | 6. Fullwidth MAP -> full-width -> half-width | every step returns { text, count } and the | counts are summed -> N v N == 0 ? -----------------------> NO write at all: the file is left | byte-identical, no ledger line, no | journal record; result: changed: false | N > 0 v Factory.Write(state, target, the ONE shared write executor: | rewritten, { signal, the fs/write-intent waterfall, the | actor: exec, standing sandbox policy, writeText end | policy: "standing" }) 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 raw-write v ledger + journal `normalized N char(s) in <path>` via Factory.Append, and the `normalized` event of the shared package_governance v2 domain via Factory.Journal | v result { path, count, changed, the diff card shows the outcome's before, after } before/after in the same turn

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

MINIMAL BLOCK + TWO KNOBS
FieldTypeDefaultVolatileMeaning
logbooleantrueyeswrite the durable ledger file
logFilestring~/.dsh/hook-dsh-normalize-file.logyesthe normalize-file ledger (separate from the family's logs)
replacementstring-yesthe 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:

normalize-file { file_path: "notes/report.md" } → result { path, count, changed, before, after } (the diff card shows the before/after in the same turn)

In Action

ONE CALL, ONE FILE, ONE COUNT

Before - the file as it sits on disk

before (what is on disk): Nova — “launch” ... Q3 — done after (what the tool writes back): Nova - "launch" ... Q3 - done

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 VIA FACTORY APPEND

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>):

hook-dsh-normalize-file: activated (replacement=-, logFile=~/.dsh/hook-dsh-normalize-file.log) hook-dsh-normalize-file: normalized 5 char(s) in <project>/notes/report.md

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

12 TOTAL
plugin-dsh-factory PARENT SERVICE

State, Append, Journal and - uniquely here - Write, the ONE shared write executor every N > 0 write goes through.

The stream sibling and the registrar of the raw-write tool - whose normalize:true chain this tool applies whole.

hook-dsh-core DSH FAMILY

The six transform tables and the generic Replace/ReplaceMap replacers this tool chains.

License: MIT.