wjingshan/dsh-plan-guard

dsh-plan-guard

DSH 的规划 skill + 写时断言插件:前者让模型动手前想清楚,后者在它重踩旧坑时当场拦住。不建通用体系,只固化真实发生过的事。

Uncategorizedagent-skillsclaude-codedeepseek-harness

dsh-plan-guard

中文 | English

Planning skills + write-time assertion plugins for DSH. The skills make the model think before it acts; the plugins catch it re-committing the exact mistakes that have already burned you. Neither half tries to be a general framework — both only encode things that actually happened.

This repo was not designed from scratch. It's the fallout of two videos: one on how to think clearly before writing code (the Grilling / Brainstorming / Explore planning frameworks), one on how to know you haven't broken anything after (turning "is it right?" from eyeballing into automatic assertions). The conclusion after watching: the first video's landing point is a prompt artifact — a skill is enough. The second one's landing point is architecture — a skill can't help; you have to hook DSH's interception seams. This repo is both of those made real.


What's in here

Planning (skills/)Assertions (plugins/)
SolvesThe problem wasn't thought through before actingSomething got quietly broken after acting
FormPrompt artifact (SKILL.md)DSH plugins (hooked into tools/pre-execute / tools/post-execute)
When it firesWhen you or the model load itAutomatically, on every file write
Portable?Open Agent Skills format — works in Claude Code etc.DSH-specific
dsh-plan-guard/
├── skills/                       ① Three planning skills
│   ├── grilling/                 adversarial one-question-at-a-time interviewing
│   ├── brainstorming/            9-step flow, forces a design doc + self-review
│   └── explore/                  read first, no artifacts by default
├── plugins/                      ② Two DSH assertion plugins
│   ├── dsh-skill-lint/           three assertions for SKILL.md
│   └── dsh-script-lint/          three assertions for shell / PowerShell
├── CONTRIBUTING.md               for people changing it: architecture + a worked rule example
├── CHANGELOG.md                  what changed, and why
├── docs/background.md            where this came from, and why each decision
├── install.sh / install.ps1      install the skills
├── uninstall.sh / uninstall.ps1  remove the skills
├── install-plugins.sh            install the plugins (copy route)
├── verify.sh                     check installed vs. this repo
└── update.sh                     pull + reinstall + verify

Install

Skills

git clone https://github.com/wjingshan/dsh-plan-guard.git
cd dsh-plan-guard
bash install.sh          # macOS / Linux
powershell -ExecutionPolicy Bypass -File .\install.ps1   # Windows

Installs into $DSH_HOME/skills (default ~/.dsh/skills) — the user-level skill root, active in every project. No restart needed: DSH watches these directories, so new skills show up in the next conversation step.

For a single project instead, copy the directories into <project root>/.dsh/skills/ (higher priority, shadows the global copy).

Plugins

There are two ways to install. The bundle install is recommended — it is managed by the profile's package manager, updates cleanly via dsh plugin, and won't be removed by unrelated package operations.

Use the plugin_manager tool with the install_bundle action from inside DSH (the Plugins page in the Web sidebar is the same entry point):

action : install_bundle
target : github:wjingshan/dsh-plan-guard#path:/plugins/dsh-skill-lint

Then the other one:

action : install_bundle
target : github:wjingshan/dsh-plan-guard#path:/plugins/dsh-script-lint

#path: is pnpm's git-subdirectory syntax — the repo root is one project and each plugin is a subdirectory of it, so the spec has to point at the subdirectory.

A successful call returns "application": "applied"; the profile's patchReload is live, so no restart.

Pinning a version: by default the spec resolves the default branch's HEAD on every install. To pin a release, put the git ref before &path::

action : install_bundle
target : github:wjingshan/dsh-plan-guard#v0.2.0&path:/plugins/dsh-skill-lint

The reverse order (#path:...&tag=v0.2.0) fails with Could not resolve ... commit — we tested it. The ref can be a tag, a branch name, or a commit SHA.

⚠️ Do not use dsh plugin add. The CLI's plugin subcommand only forwards its arguments to pnpm (dsh plugin --profile web add <pkg> is pnpm add <pkg> in the profile directory) — the package does install, but it is not registered in dsh.profile.bundles, so this bundle's patch never applies and the plugin never loads. We verified that difference the hard way: the package was installed and the seams were dead.

install_bundle does three things: runs the pnpm install, registers the package in dsh.profile.bundles, and verifies the bundle loads. For GitHub specs it also probes reachability with git ls-remote first, and it checks DSH peer-version compatibility after installing.

Route B: copy install (offline, or when hacking on the source)

bash install-plugins.sh

Route B: copy install (offline, or when hacking on the source)

bash install-plugins.sh

It copies both packages into <profile>/node_modules/ and appends insert entries to <profile>/cordis.patch.yml.

⚠️ This route is not durable — we have seen it fail. A package dropped into node_modules/ by hand is not tracked by the package manager, so it counts as extraneous. In our case a single pnpm install on the profile (triggered by an unrelated plugin) removed one of the plugins and rewrote both insert entries out of cordis.patch.yml — both plugins silently stopped working, with no warning at all, until someone went looking.

Use this route only for a quick trial or while debugging the source. For anything durable, use Route A.

For source debugging a symlink is less work (the plugins have zero runtime dependencies, so a symlink resolves fine):

ln -s "$PWD/plugins/dsh-script-lint" "$PROFILE/node_modules/dsh-script-lint"

Windows

Route A's dsh plugin commands are identical (run them in PowerShell). For Route B, do the same two steps by hand: copy both directories from plugins into the profile's node_modules, then append the insert entries to <profile>/cordis.patch.yml.

Confirming it actually installed

./verify.sh

It checks whether what's installed matches this repo, and distinguishes a bundle install from a hand copy. It also errors when a patch entry exists but the package doesn't — that makes DSH fail to load. Run it once after installing; it saves you from "I thought it was installed".

Updating

./update.sh            # pull, reinstall only what was installed, verify
./update.sh --no-pull  # you already pulled
./update.sh --check    # just look for new commits upstream, change nothing
./verify.sh            # check only, change nothing

Why git pull alone is not enough: installing copies files into node_modules / skills, so new code pulled down does not replace the copy that's in use. update.sh is that missing step.

Bundle-installed plugins are managed by dsh plugin; update.sh prints the exact dsh plugin add command instead of guessing.

The three planning skills

SkillMetaphorWhat it does
grillingAn aggressive interviewerOne question at a time, down the design tree, no "good enough" answers
brainstormingA strict project managerNine steps, ends in a committed design doc plus self-review
exploreA curious thinking partnerRead code, compare options, ASCII diagrams — produces no artifacts

Load with /grilling, /brainstorming, /explore, or just say "poke holes in this plan".


The two assertion plugins

dsh-skill-lint

DSH's skill loader has two silent failure modes — it fails, and you can't tell.

FM_INVALID · NAME_MISSING · NAME_NOT_KEBAB · NAME_MISMATCH · DESC_MISSING · BOOL_INVALID · SIZE_OVER

Any of the first six means DSH silently discards the whole skill. The model can't tell "this skill doesn't exist" from "this skill is malformed"; only a warning lands in the log.

dsh-script-lint

All three rules come from real incidents:

CodeCatchesFrom
SHELL_ERREXIT_TRAPUnder set -e, x=$(... grep ...) failing kills the whole script with no errorA packaging script exited 1 silently; took a long time to find
SHELL_GREP_UNQUOTED_PATHRecursive grep with an unquoted $VARgrep -rn ... $D with $D empty → recursively searched the entire workspace
PS1_NO_BOM.ps1 with non-ASCII but no UTF-8 BOMPowerShell 5.1 decodes it as ANSI; Chinese turns to mojibake

SHELL_ERREXIT_TRAP is the subtle one, because it encodes bash semantics — position and shell options change the answer:

FormUnder set -e
x=$(grep a f)the substitution's exit code is the assignment's❌
echo $(grep a f) / [ -n "$(…)" ] / for f in $(…)doesn't affect the outer status✅
x=$(ls | grep v | wc -l) without pipefailonly the last stage counts✅
same, with pipefailany stage failing counts❌
x=$(grep a f || true)failure explicitly swallowed✅
x=$(grep a f) | catruns in a subshell; can't kill the script✅
cat f | { x=$(grep a f); }the compound returns non-zero — still fatal❌

So the message names every fragile stage in the pipeline, not just the first — naming only the first sends you to fix the wrong line.


Three design decisions

1. Criteria and seams are separate. Each plugin's src/ splits into pure-function criteria (lint.mjs / rules.mjs — zero dependencies, independently testable) and thin seam wiring (index.mjs). A wrong measurement and a wrong criterion can be audited separately, without one masking the other. Borrowed from dsh-design-audit's own "measurement and criteria are separate" principle.

2. The two seams behave differently — this is the design, not an implementation detail.

  • write → tools/pre-execute → deny: the content hasn't landed yet, so the bad file never exists.
  • edit → tools/post-execute → block: edit takes {old_string, new_string}, so the new full text doesn't exist before the call. It can only re-read from disk afterwards and report. And it cannot undo what already landed.

We deliberately don't undo. An assertion that claims "I rolled it back" without rolling anything back is more dangerous than one that plainly says "this file is now broken".

3. Prefer over-reporting, but keep criteria narrow. A missed report is silent; an extra one is at least visible — hence no path restriction by default. But a linter noisy enough to disable is the same as no linter, so each rule only fires on high-confidence shapes, and every rule was verified against the real scripts in this repo for zero false positives.


Tests

node --test plugins/dsh-skill-lint/test/*.test.mjs     # 24 assertions
node --test plugins/dsh-script-lint/test/*.test.mjs    # 39 assertions

dsh-script-lint's headline fixture is the real buggy line from the packaging script — the pre-fix version must be caught, and adding || true must clear it.


Known limits

  1. Only intercepts DSH's write / edit tools. cp, an editor, or any shell command writing a file bypasses this entirely.
  2. Not a bash parser. Line-by-line, quote-aware scanning. eval, ${!x} and friends are invisible to it.
  3. Both plugins hook tools/pre-execute, and the first deny in the waterfall short-circuits — a file tripping both sides reports one issue at a time.
  4. This is not a regression suite. No case corpus, no scoreboard, no feature-flag A/B, no coverage. Assertions prevent repeating a known mistake; they don't prove a change broke nothing else.

Point 4 is deliberate: that machinery (non-determinism, cost, LLM-judge signal-to-noise) is an order of magnitude harder. Don't build it before your scale demands it.


License

MIT