@playform/ets-hook-dsh-normalize-file
v0.1.0
Published
The file-content normalizer flavor: 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 c
Readme
@playform/ets-hook-dsh-normalize-file
The DeepSeek Harness Plugin Family for PlayForm.
[!NOTE]
The file-content normalizer - a DeepSeek Harness plugin that registers the normalize family's
normalize-fileTOOL: 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, nofs/observed- nothing runs without an explicit agent action.The background-rewrite posture is deliberately NOT shipped (it would break the edit tool's
old_stringcontract); the design report ships the tool arm only.The @-sentence identity: Hook @ DSH @ Normalize @ File.
This bundle's own tool calls are name-exempt in the stream gate beside
editandraw-write- its arguments carry a FILE PATH, and a normalized dash inside a filename would corrupt the target.
Where It Fits
Family position (the @-sentence Hook @ DSH @ Normalize @ File): a tool-child of the
ets-plugin-dsh-factory service and the ets-hook-dsh-core
machinery; the seventh sibling - the one flavor that works on files already on disk instead of model
output.
One of seven siblings in the normalize family, all built on the shared machinery -
ets-plugin-dsh-factory for State/Append/Journal/Write (and the named
Schema helper, shared: false, for the config), ets-hook-dsh-core for the six
transform tables and the generic Replace/ReplaceMap replacers:
| flavor | layer | mechanism |
| ----------------------------------------------------------------- | -------------------------------- | -------------------------------------------------- |
| ets-hook-dsh-normalize-dash | model output (stream) | core Dashes class -> replacement (default -) |
| ets-hook-dsh-normalize-quotes | model output (stream) | core Quotes MAP -> curly -> straight |
| ets-hook-dsh-normalize-ellipsis | model output (stream) | core Ellipsis class -> ... |
| ets-hook-dsh-normalize-spaces | model output (stream) | core Spaces class -> " " |
| ets-hook-dsh-normalize-invisible | model output (stream) | core Invisible class -> removed |
| ets-hook-dsh-normalize-fullwidth | model output (stream) | core Fullwidth MAP -> full-width -> half-width |
| ets-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 file-hook precedent is direct: the normalize-dashes-for-execute-code.sh
hook 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
config is composed by the factory's standalone Schema helper with shared: false - the minimal
block plus the two knobs.
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.
Install
Terminal (registry)
pnpm add @playform/ets-hook-dsh-normalize-file- Remote / dependency install (auto-activation):
pnpm add @playform/ets-hook-dsh-normalize-filein a profile dir (+ the package name indsh.profile.bundles, ordsh plugin add) - installed automatically and activated at the next host start. - Local git clone: clone →
pnpm installinside the bundle (itspnpm-workspace.yamlpinspackages: [.]so the install lands in the bundle, not the workspace root) → build →dsh plugin --profile <name> add <this directory>. - Tarball / npm:
dsh plugin --profile <name> add <tarball-or-spec>- the package ships builtTarget/(minified,.d.tstwins), so no build runs at install time.
Verify with the ets-hook-dsh-normalize-file: activated (...) ledger line.
Usage
The tool is called by the agent like any built-in tool - the bundle is configured through its
cordis.patch.yml row (or the profile's dsh.bundle manifest), and the full config table is in
The Config:
normalize-file { file_path: "notes/report.md" }
→ result { path, count, changed, before, after } (the diff card shows
the before/after in
the same turn)The shared Effect-TS plumbing - the runtime wiring, the stream machinery and the hook contract types - comes from the base ets-base-dsh package.
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.
The lesson is real: normalize-tabs.sh, a repair hook for a
repair hook, exists to repair the damage after-the-fact rewriting caused.
The tool is the answer: explicit, visible, discoverable, zero background activity.
How It Works
The pipeline
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 turnThe 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 theirmutationToolspin -normalize-fileis never added to any such list, so these writes never trigger a chain pass. - Edit
old_stringcontract: safe because visible - the diff card shows the before/after in the same turn, and the tool description says to re-read before editing (the same obligation as after anynormalize: truewrite). - raw-write read-before-write: tool calls are serialized and Factory.Write's
beforeis read at write time, so a prior normalize-file in the same turn is already reflected. No race.
The Pitfalls
- The file's bytes change under you - re-read before editing: the edit tool's
old_stringmust match the NEW content; the diff card shows the before/after in the same turn. - A no-op is a no-write - N = 0 leaves the file byte-identical: no write, no ledger line, no journal record; a missing, binary or undecodable target errors out the same way.
- No governor triggers - the write emits fs/observed with the call's exec as the actor, but
normalize-fileis never in a governor'smutationTools, so no chain pass runs. - Name-exempt in the stream gate - the tool's arguments carry a file path and pass through the
stream unnormalized, beside
editandraw-write.
The Config
| Field | Type | Default | Volatile | Meaning |
| ------------- | ------- | ------------------------------------ | -------- | ----------------------------------------------------------- |
| log | boolean | true | yes | write the durable ledger file |
| logFile | string | ~/.dsh/ets-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 factory's State builder unwraps them defensively.
The Granular Scopes (all volatile)
The scopes object gates every arm of the ets-hook-dsh-normalize-file hook independently — the defaults are exactly the
pre-scopes behavior, and because the schema shape is identical in every family hook, one grouped
override in the profile patch turns the whole family off in one edit (see the patch fragment at the
end of this section). The whole object carries the volatile marker (its leaves stay plain —
schemastery rejects volatile leaves under an enclosing volatile field): one leaf change commits one
loader/volatile-update dispatch whose whole object the State's refresh listener re-reads — live,
silent, no remount, no new activation line.
| Scope | Default | Gates |
| ------------ | ------- | --------------------------------------------------------------------- |
| conversation | on | text-delta text + the assembled text/reasoning blocks |
| reasoning | on | the reasoning-delta sub-gate (ANDed with normalizeReasoning) |
| toolInput | off | the tool-call arguments arm (OR-ed with normalizeToolArguments) |
| toolResults | off | the tools/post-execute arm (tool results in the transcript) |
| files | on | the normalize-file tool — off = the tool returns the unchanged changed:false result, no read, no write, no ledger line |
| batch | off | the agent/pre-step arm (the incoming step batch) |
normalizeToolArguments is kept and OR-ed in (effective toolInput = the field ||
scopes.toolInput), so every existing profile keeps working.
The per-call {"__normalize":false first-key marker is the tool-args arm's escape hatch — the
"leave that search alone" control: a deliberate search for an irregular character (an em dash, a
curly quote, a full-width form as a PATTERN) survives byte-exact while the rest of the call stream
normalizes. Documented edges: the marker must be the FIRST key, and a pathologically tiny first
delta (shorter than the marker) is invisible to the startsWith probe — the call normalizes
normally.
toolResultsExempt (volatile): a tool result whose exec name is listed passes the tool-results arm
by identity — read, edit, raw-write, normalize-file, ccr_retrieve, subagent,
ask_user_question by default.
The Overpass Arms
Two additional listeners, both registered at apply and both opt-in (gated at RUNTIME on the scope cells, so flipping a scope lands through the P10 volatile-refresh without any re-registration):
- Tool results —
tools/post-execute,{ prepend: true }(OUTERMOST vs the ccr hook's own prepended gate: the ccr compression sees the NORMALIZED bytes; the<<<CCR:marker guard makes any load order safe). Accepted, non-value-carrying decisions only; TEXT blocks only (never tool-call/argument blocks); N = 0 is a true no-op — no ledger line; fail-open. The invisibility contract: the replacement is PRE-COMMIT — the waterfall settles before the result is appended to the durable log — so the model only ever sees the normalized output and never notices the replacement (the write-path counterpart is the governance trio's never-notify precedent: the tool result is built from the author's own content, and the observation-policy's version record is refreshed by the re-emitted fs/observed). - Step batch —
agent/pre-step, default order. Whilescopes.batchis on every incoming batch is normalized; the command's one-shot arm fires ONCE regardless of the scope value and is cleared after one use. Text blocks only; never the assistant/tool history.
The file overpass lines (N > 0 only):
ets-hook-dsh-normalize-file: overpass: normalized N char(s) in tool result <toolName> <callId>
ets-hook-dsh-normalize-file: overpass: normalized N char(s) in step batch (turn T step S)
ets-hook-dsh-normalize-file: overpass: swept K file(s), normalized M char(s)The normalize-overpass Command
Registered through the commands service when it is present (probe-once — a missing service simply
skips the registration). Grammar:
| Input | Effect |
| ------------------ | --------------------------------------------------------------------------------- |
| files <path…> | run the file sweep — the normalize-file pipeline per path, each changed file written through the shared executor with its own count line, then the summary |
| batch | arm the one-shot step-batch overpass (consumed by the next incoming batch) |
| results on\|off | flip scopes.toolResults via the settings service / config editor — never direct State mutation; absent services → reports the settings-UI path |
| scopes | read-only report of the current effective values (the activation line never changes) |
The Patch-Level Global Off
- id: ets-hook-dsh-normalize-file
config:
scopes:
conversation: false
reasoning: false
toolInput: false
toolResults: false
files: false
batch: falseIn Action
One call, one file, one count.
The file already on disk carries the family's characters; the tool rewrites what changed and reports it:
before (what is on disk):
Nova — “launch” ... Q3 — done
after (what the tool writes back):
Nova - "launch" ... Q3 - doneEvery 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:
ets-hook-dsh-normalize-file: normalized 5 char(s) in ~/Projects/notes/report.mdA 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 ets-hook-dsh-normalize-file: prefix is the
logger's <State.Module>:; the durable file line is [<ISO>] <message>):
ets-hook-dsh-normalize-file: activated (replacement=-, logFile=~/.dsh/ets-hook-dsh-normalize-file.log)
ets-hook-dsh-normalize-file: normalized N char(s) in <path>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.
License 📜
CC0-1.0.
