huxin7735-collab/dsh-maintainer-doc-guard

dsh-maintainer-doc-guard

Keeps a long agent turn answerable to the user's actual request: a standing maintainer-document reminder, two pre-execute gates, an objective anchor quoting your latest instruction, and a nudge stage that corrects before it blocks.

未分類

dsh-maintainer-doc-guard

A DeepSeek Harness (dsh) bundle that keeps long-term project memory outside the context window — and keeps a long turn answerable to the request that started it.

Why

A small-parameter model that "thinks hard" builds a long context; the harness compacts it; the compacted summary silently drops the project's conventions, its plan, its stack decisions and its working state — and the model drifts.

The proven fix (measured on DeepSeek V4 Flash) is to externalise that memory into maintainer documents and make the model read them every turn, so memory is re-hydrated from files instead of recalled from a lossy summary:

.dsh-maintainer-doc-guard/
  <session-id>/            # one isolated set per conversation
    plan.md   conventions.md   stack.md   state.md   maintainer/README.md

The five documents live in a per-session subfolder under .dsh-maintainer-doc-guard/ (override the parent with the docsDir knob). Each conversation gets its own set, so two sessions in the same workspace never share or mix the same file. The plugin creates the session folder and a blank file for each document on first open of the panel, so there is nothing to set up by hand and reads are instant.

What this plugin does

It has five parts, plus five advisory notices added in 0.5.3 (see The advisory notices below).

1. A standing reminder (a system-prompt section). It registers one system-prompt section (maintainer-doc-guard) that lists the maintainer documents present in the workspace and instructs the model to read the relevant one before every operation or thinking round.

Why a prompt section and not a pre-step message: a pre-step message is persisted to the session log (that is how instruction-hint dedupes its hint), so injecting one per step would flood the history. A section is re-evaluated on every prompt assembly — every turn — and never accumulates. That is exactly the "standing, per-turn instruction" this needs.

2. A pre-write precedent gate (a tools/pre-execute guard). The harness already makes a model read a file before overwriting it (dsh-fs-observation-policy → FS_NOT_OBSERVED). That covers the file it replaces, but not the contract it must obey — so authoring a brand-new infrastructure file (a plugin, a preset, a skill) from memory is how invented APIs reach a running harness.

The gate closes that half. A write/edit whose target lives in a guarded infrastructure area is denied until the session has read at least one same-basename precedent that already works. The denial is model-visible and lists concrete candidate files to read:

Blocked by maintainer-doc-guard: you are about to write "<target>", which lives
in a guarded infrastructure area, but this session has not read any working
"index.js" precedent that shows the real contract.

Read one of these first, then retry:

- /…/node_modules/@deepseek-ai/pkg-alpha/lib/index.js

Why: infrastructure written from memory is how invented APIs reach a running
harness. If the file already exists, read it; if you are creating it, read a
working sibling — the analogue you copy is what keeps the new file consistent
with the real API.

Design rules it follows:

  • Monotonic. The listener delegates downstream first and only then applies its own deny, so it can never force an allow another guard refused.
  • Bounded. After gate.maxDeniesPerTarget denials for one target it lets the call through and logs — a stubborn model cannot deadlock its own turn.
  • Never fatal. Every path is wrapped; an internal failure degrades to "no decision" and warns. A guard bug must not break tool dispatch, exactly as a section bug must not break prompt assembly.
  • Per-session, in-memory. Observed reads are keyed by the opaque agent.session identity and never persist, so a recovered session re-reads its precedent — the same semantics dsh-fs-observation-policy uses.
  • Coarse by design. "Same basename" is a proxy for "a working analogue of this kind of file", not a proof. That keeps false denials rare, which is the right trade for a speed bump whose value is forcing one conscious look.

Candidates are found by walking up the directory chain and asking, at each level, whether sibling packages carry the same relative path — for …/node_modules/<pkg>/lib/index.js the first hit is …/node_modules/<other>/lib/index.js, which is exactly the file a careful author reads first.

3. An intent gate (a second tools/pre-execute guard). The precedent gate polices how a change is written; it says nothing about whether the change was worth making. The intent gate polices that, and it is the cheaper half: a turn that has not said what its step is for gets its first side-effecting call denied once. One round trip buys an explicit statement of purpose — and, when the purpose cannot be stated, the honest answer is to stop and ask rather than to run the command and find out.

Its design rules differ from the precedent gate's in one place: it is turn- scoped, not target-scoped, because what it measures is a property of a turn's reasoning, not of a file. It is also the only guard here that stands down on a missing sensor — see the note on fail-open under The intent gate.

4. An objective anchor (a second system-prompt section). Both gates above police how a step is taken. Neither notices that the objective was replaced — which is the actual mechanism behind "it burned a whole turn on something nobody asked for". A long turn drifts because the model's own last paragraph becomes its new premise: the user's instruction recedes into a compacted history while a self-discovered lead takes its place, and every later step is locally reasonable.

The anchor keeps the user's latest instruction quoted verbatim in the system prompt, with an explicit precedence rule:

## Current objective — the user's latest instruction

> 修改一下估算的,这是任务1,任务2是……

This is the instruction the current work is answerable to. It is quoted from the
user's last message, so it outranks anything you inferred since.

Precedence when these disagree:
1. the user's latest instruction, quoted above
2. the plan you stated in your own previous turn
3. a lead, idea, or side-quest you found by yourself

Work the first item does not cover is your own excursion, not the task. Say so in
one line before you spend another step on it, and ask before it costs more than
that.

Two design choices worth naming:

  • It is a separate section at a late order (anchor.order, default 10150), not text appended to the document reminder. An unchanged prompt keeps prefix (KV) reuse, and a changed one loses it from the first changed token — and this is the one section that changes whenever the user speaks. Sitting after the Web surface (10100) and before the deployment persona suffix (10200) keeps the damage to the smallest tail in the assembly.
  • Only a message the user actually authored becomes the anchor. The guard's own notices and the loop's runtime-context snapshot are user/message events too; source.kind === 'user' is the discriminator the loop itself uses, so the anchor can never end up quoting the guard back at itself.

Inside a delegated child

The anchor is off in subagents by default, and the reason is not performance — it is correctness. A delegated child (dsh's subagent tool) is started by a delegation message, not by something a human typed, so the parent's "latest user instruction" is not the child's job. Rendering it there would invert the very precedence rule the anchor exists to enforce, and invite the child to chase the original request instead of the task it was handed.

Set anchor.inSubagents: true when you do want it, and the section switches identity for that reader:

## Your delegated task — what you were sent here to do

> 把解析器改成允许尾逗号,周五前。

This is the task you were delegated. It is quoted from the message that started
your session. It outranks anything you inferred since, and it does not widen by
being interesting.

Precedence when these disagree:
1. the delegated task, quoted above
2. the plan you stated in your own previous turn
3. a lead, idea, or side-quest you found by yourself

Two mechanics make that work:

  • The delegation is adopted positionally, per lineage. A child's opening message is delivered through the ordinary user-message path, so source.kind cannot tell it apart from a human's. Instead a child adopts the first non-empty user-role message it sees and keeps it; a later real send does not quietly replace the task it was given. At the top level nothing changes — the latest user message still wins, and plugin/tool notices are still ignored.
  • Reach is one switch per contribution. The document reminder and the anchor are separately switchable (inSubagents / anchor.inSubagents) because they want opposite answers here: the documents are inherited through the parent's cwd and worth reading; the parent's objective is not the child's task. true opts a contribution into children and grandchildren; the default, false, or anything unrecognised keeps it at the top level, so a typo fails silent.

Both switches are composition-only — see cordis.patch.yml, not the settings card, which holds exactly ten fields.

The anchor renders to an empty string — and therefore disappears, costing no tokens — until the session has seen a user message. Because the loop appends the accepted user batch during the step that claimed it, the next assembly already carries the new objective; the turn's own first step is the one place where the user's message is still the last thing in the request, so there is nothing for an anchor to disambiguate yet.

5. A nudge stage (correct before you block). An interception that only fires after a whole turn has burned is a receipt, not a correction. So the intent judgement is staged, and the cheap stage comes first:

#what happenscost
1st unexplained actiona reminder is injected (agent.inject) and the call runs0 round trips
2nddenied, with the request to say what the step is for1 round trip
3rddenied again, until intent.maxBlocksPerTurn is spent1 per denial

The reminder reaches the model at its next step boundary, so the correction lands inside the turn that needs it instead of in the next one. Two reminders exist:

  • the step-purpose nudge, injected instead of the first denial;
  • the drift check, injected once when a turn has run nudge.afterSteps steps without the user speaking again — the one drift signal that needs no semantic judgement about what the model is doing, because the step count is a fact and the fact is what "burned a turn on a side-quest" looks like from outside. It fires even for a turn that narrated itself perfectly, because a well-narrated turn can drift just as far.

The channel is deliberately agent.inject rather than the post-execute additionalContexts field: additionalContexts exists only on the POST-dispatch decision, while the pre-dispatch PreToolDecision is allow/deny/ask and cannot carry context — so a reminder that must arrive before an action has to travel through the agent. steer is the waking half of the same pair and is not used, because a guard has no business starting a turn on an idle driver. Delivery failure degrades to a plain allow and counts nudgeFailed; the staged path can never turn into a blocking path by accident.

Set nudge.grace: 0 to restore the pre-0.5 behaviour (deny the first offence), nudge.enabled: false to drop the stage entirely, or nudge.afterSteps: 0 to keep the nudges but drop the drift check.

Operator documents are read-only

The model does not write the operator's documents. A write, edit or doc_write whose target is plan.md or conventions.md — or a shell command that WRITES one — is denied outright, deliberately WITHOUT the precedent escape the infrastructure gate offers: reading another plan.md does not license rewriting this one. The denial redirects the model to lessons.md, the one document it owns, and records that gate.operatorDocsReadOnly: false lifts the rule, and the right-sidebar switch (操作者文档只读) toggles it at runtime — process-level, lost on restart.

The same rule decides what a seed can do: CONVENTIONS_SEED now carries ten rules (8 workspace notes that stay short and classified, 9 notes live in write/ and other people''s documents are off limits, 10 delegation to subagents is allowed), but the seed only applies where the file is CREATED. An existing workspace keeps its own conventions.md — the plugin never rewrites it.

Templates, and the three views

The sidebar tab is one tab with three views, because one button per function in a single row stopped being readable: documents (reload / save / strict mode), settings (the six process-level switches, open folder, sweep orphans), and templates (save the current conventions.md as a named template, then apply any template to the current workspace). Templates live in a SHARED store — $DSH_HOME/maintainer-doc-guard-templates/ — precisely so a set of iron rules can be carried into a workspace whose conventions.md already exists, which the seed can never do. Both directions go through the panel (the operator's channel), never through the model's gate.

The advisory notices (0.5.3)

Five notices added on top of the five parts. All five are ON by default, all five observe only and never block, and all five can be switched from the right-sidebar tab (process-level, like the strict-mode switch) — they are deliberately NOT new settings-card fields, because that card has a hard ceiling of ten fields.

On-demand ledger hits. lessons.md is listed alongside the operator's documents but is NOT force-injected: a line is delivered only when its 触发 field matches the live call — same tool, plus at least one path/command fragment present in this call (substring match; * splits a fragment into AND-ed pieces). One hit per line per session. Cross-conversation reuse comes from the workspace-level <cwd>/.dsh-maintainer-doc-guard/lessons.md, not from re-stating it every step.

Landing check for deleted numbers. For edit, a net decrease in number runs between old_string and new_string is the objective trace of the observed failure — deleting the sentence that carried a number or a conclusion. The notice asks for the landing-place check before that deletion stands.

Completion standard, once per artifact. After a successful write to a code artifact (.mjs/.cjs/.js/.jsx/.ts/.tsx/.ps1/.sh/.py/.json/ .yml), the next call is reminded once to state how it was verified and the actual reading — not to mark it done.

Auto-induction. A failed call on a managed tool, followed by a successful call on the SAME target, appends one line to the ledger: the failure's own error text as the cause, and which argument keys changed as the fix. Only observed facts; shell calls are excluded (a command string has no stable "same target"); capped at three lines per session; deduped by full line.

Subagents: the notices are asymmetric

Ledger hits and auto-induction are OFF inside a delegated child by default: isChildSession(session) && !subagentOptIn(config.inSubagents) returns "no notice / no booking", the same policy as the document reminder (opt a child in with inSubagents). The number-landing check and the completion reminder have no child check at all, so they DO run in children — and because all four share one process-level switch group, a parent cannot quiet its children independently.

Costs and trade-offs (0.5.3)

What it buys. The model stops losing the thread: it stays on topic, and it stops "solving" a long task with a grand detour that goes nowhere. In practice that is a noticeably more comfortable long-run experience than 4.0.

What it costs — the honest list.

  1. It touches the context, so prompt-cache hits sit at roughly 70-80 percent (operator's measurement). The force-injected documents and the appended anchor both rewrite part of the prompt every round, so the rest of the prefix has to be recomputed. That is the main bill for "stays on topic".
  2. Notices repeat after a reload. Hot-reloading or restarting the plugin clears per-session bookkeeping (read set, notice version, induction count), so "you have not read these documents" can fire again right after you did.
  3. Subagent behaviour is asymmetric (see the section above).
  4. Fragment matching is substring-based. A trigger written as edit + index.js fires for any path containing index.js, including edits to unrelated files.
  5. Auto-induction is partial by construction. Only "same target: failed, then succeeded", shells excluded, at most three lines per session — and when the retry changes no argument key, the recorded line says little.
  6. The notices cost context too, share one per-round budget, and the second document reminder embeds the documents themselves.
  7. The settings card is capped at ten fields, so newer switches live in the sidebar: process-level, lost on restart, persistence only via cordis.patch.yml.
  8. A notice is advice, not a guarantee. Only the precedent and intent gates actually deny, and both let the call through after a bounded number of tries.

A delegated child's report is input, not a conclusion. After a subagent or subagent_fork result lands, the parent is reminded once to re-run the key readings before adopting a number, a bound or a counterexample; to check the work tree (git status --short), because a child told to be read-only can still create files — that is an instruction, not a sandbox; and to specify the output path positively next time instead of only forbidding writes. Armed on the result and delivered on the next tool call, like the completion reminder.

Configuration

Row config (all optional):

keydefaultmeaning
enabledtrueturn the section off without removing the row
docs["plan.md","conventions.md","stack.md","state.md","maintainer/README.md"]document names to probe
docsDir".dsh-maintainer-doc-guard"folder (relative to each workspace) that holds the documents; created on first access
onlyWhenPresentfalsewhen true, stay silent unless at least one document exists
inSubagentsfalsealso inject the document reminder into delegated children (true opts in)
order100section sort order (after the persona prefix at 0)
walkUp6ancestor levels to walk looking for the project root
projectMarkers[".git"]root markers for the walk-up
title / introbuilt-inoverride the reminder's heading / body text

Gate config lives under gate: and is also all optional:

keydefaultmeaning
gate.enabledtruemount the tools/pre-execute guard at all
gate.dryRunfalseobserve only: count what it would deny, never block
gate.tools["write","edit"]tool names the gate examines
gate.guardedGlobs[".dsh/profiles/", ".dsh/skills/", ".dsh/.agent-presets/", "node_modules/@deepseek-ai/"]POSIX substrings of the resolved target path that make it guarded
gate.exemptGlobs[]POSIX substrings that exempt a target even if guarded
gate.maxDeniesPerTarget2denials for one target before the gate gives up and lets it through
gate.maxCandidates3precedents listed in one denial
gate.scanLevels4ancestor levels scanned for sibling precedents
gate.entriesPerLevel400directory entries examined per level (caps node_modules scans)

Example — observe before enforcing:

- id: maintainer-doc-guard
  config:
    gate:
      dryRun: true

The gate is off for anything outside guardedGlobs, so ordinary project files (plan.md, source, notes, scratch scripts) are never touched by it.

Each document is probed in the session working directory first, then in the project root, so a document kept at the repo root is found even from a nested cwd.

Everything this plugin copies from outside itself — the quoted user instruction, document names, title / intro overrides — is defused for the harness's {{variable}} interpolation before it reaches the prompt (the two-character opener and closer are split apart), because rendering throws on an unregistered reference. A user who types {{x}} therefore cannot break prompt assembly for the rest of the session.

Anchor config lives under anchor: and is also all optional:

keydefaultmeaning
anchor.enabledtruerender the standing objective section at all
anchor.inSubagentsfalsealso render inside delegated children, quoting the child's own delegation under child-specific wording (true opts in)
anchor.order10150section sort order — late on purpose, so the section that changes on every user message invalidates as little prefix as possible
anchor.maxChars800clip the quoted instruction, marking the cut with … (truncated)
anchor.title / anchor.introbuilt-inoverride the heading / the framing sentence
anchor.prioritiesthe three-step rulethe precedence list, rendered as a numbered list after the quote
anchor.childTitle / anchor.childIntro / anchor.childPrioritiesbuilt-inoverride all three for the child reading — each defaults to wording that says "your delegated task" rather than "the user's latest instruction"

Nudge config lives under nudge: and is also all optional:

keydefaultmeaning
nudge.enabledtruestage the intent judgement at all; false denies the first offence, as 0.4 did
nudge.dryRunfalseobserve only: count what it would inject, inject nothing
nudge.grace1unexplained actions allowed through with a reminder before any denial
nudge.maxPerTurn3reminders per turn before it stops injecting (the deny budget is separate)
nudge.afterSteps12step count that triggers the once-per-turn drift check; 0 disables it

Settings page — the ten live knobs

Both gates' most-touched switches are also editable at runtime, in the harness's own settings UI under 设置 → 插件 → 插件配置 ("Plugin configuration"), with no restart:

settings fieldmaps todefault
gateEnabledgate.enabledtrue
gateDryRungate.dryRunfalse
gateMaxDeniesPerTargetgate.maxDeniesPerTarget2
intentEnabledintent.enabledtrue
intentDryRunintent.dryRunfalse
intentMaxBlocksPerTurnintent.maxBlocksPerTurn1
intentMinCharsintent.minChars24
anchorEnabledanchor.enabledtrue
nudgeGracenudge.grace1
nudgeAfterStepsnudge.afterSteps12

The card lists the ten fields in guard order — precedent gate, intent gate, objective anchor, then drift correction — each with its own label and hint, so it is never ambiguous which guard a switch belongs to.

How it works, and what it deliberately does not do:

  • The host half registers a settings namespace (maintainer-doc-guard) through ctx.settings.installSection(...) — guarded by ctx.inject(['settings'], …), so a deployment without a settings provider simply keeps using the composition config instead. Nothing here is required for the gate to work.
  • Values persist to the harness settings document ($DSH_HOME/settings.yaml), which is hot-reloaded; installSection falls ba