dsh-rules 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rj-jiangyichen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # dsh-rules
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![DSH Plugin topic](https://img.shields.io/badge/topic-dsh--plugin-blue)](https://github.com/topics/dsh-plugin)
5
+
6
+ **English** | [中文](./README.zh.md)
7
+
8
+ Glob-activated rule prompts for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) — a Claude Code `rules.md` / `# Path:`-style mechanism. Each rule declares glob patterns; when the agent **reads or edits** a matching file, the rule activates and its content (any prompt or markdown document) is injected into the conversation as a superseding `<rules>` snapshot.
9
+
10
+ > Works in **every DSH deployment**: desktop / web / tui / headless / custom profiles — nothing about this plugin is desktop-specific.
11
+
12
+ ## Contents
13
+
14
+ - [Features](#features)
15
+ - [How it works](#how-it-works)
16
+ - [Installation](#installation)
17
+ - [Rule format](#rule-format)
18
+ - [Configuration](#configuration)
19
+ - [Discoverability](#discoverability)
20
+ - [Limitations](#limitations)
21
+ - [Development](#development)
22
+ - [License](#license)
23
+
24
+ ## Features
25
+
26
+ - **Glob activation** — rules activate per file the agent touches: `**`, `*`, `?`, `{a,b}`, `[abc]`, and `!` negation (picomatch).
27
+ - **Claude Code compatible** — plain rule files (`.dsh/rules/*.md`) *and* `# Path:` sections inside `CLAUDE.md` / `AGENTS.md`.
28
+ - **Visible & durable** — active rules are injected as a user message the UI shows and the session log persists; each snapshot supersedes earlier ones, so the model always sees the current set.
29
+ - **Budget-bounded** — byte-budget rendering (32 KB default): low-priority rules are dropped first, then the last rule is truncated; content is escaped so it can never break out of the framing tags.
30
+ - **Resume-friendly** — on session resume the last snapshot and its matched files are restored from the log, preventing duplicate injection.
31
+ - **Per-session tracking** — every agent/session tracks its own touched files (subagents included); global rules (no `path:`) are always active.
32
+
33
+ ## How it works
34
+
35
+ ```
36
+ workspace
37
+ .dsh/rules/*.md ← rule definitions (frontmatter declares globs)
38
+ ~/.dsh/rules/*.md ← user-level rules (optional)
39
+ CLAUDE.md ← optional: # Path: sections (Claude Code compatible)
40
+
41
+ agent reads/edits a file (fs/observed) → record per-session touched path
42
+ ↓ every step (agent/pre-step)
43
+ match touched paths against globs → collect active rules → render a <rules> snapshot into the conversation
44
+ ```
45
+
46
+ - **Injection point**: an `agent/pre-step` waterfall listener appends a `<rules>`-framed user message; a new message is only appended when the snapshot text changes.
47
+ - **Discovery & caching**: rule sources are re-probed per step with version caching (`fs.stat().version`, or `mtimeMs:size` on the Node fallback) — edits to rule files take effect on the next step.
48
+ - **Reads**: prefer the harness `fs` service (containment-aware); fall back to Node's filesystem when no `fs` service is mounted.
49
+
50
+ ## Installation
51
+
52
+ ### Any DSH deployment (generic)
53
+
54
+ Published on the npm registry — `dsh plugin` installs **and activates** the plugin in one step:
55
+
56
+ ```powershell
57
+ # Adjust the profile name: desktop / web / tui / headless
58
+ dsh plugin --profile desktop add dsh-rules
59
+ ```
60
+
61
+ The package declares `dsh.bundle.patch`, so the reconcile pass of `dsh plugin add` appends `dsh-rules` to the profile's `dsh.profile.bundles` layer list automatically — **no manual `cordis.patch.yml` edits are needed**. Restart DSH (restart the desktop app; restart the web/headless process) and the plugin loads with the next Cordis composition.
62
+
63
+ Updates: `dsh plugin --profile desktop update dsh-rules` (or remove + add).
64
+
65
+ Installing from a local checkout (development):
66
+
67
+ ```powershell
68
+ # From the repo root — activates the bundle automatically, same as the registry install
69
+ dsh plugin --profile desktop add .
70
+ ```
71
+
72
+ > ⚠️ pnpm splits `add` arguments on spaces, so a repository **path containing spaces** must be installed through a no-space junction (see below).
73
+
74
+ ### DSH Desktop (Windows) one-click script
75
+
76
+ ```powershell
77
+ # 1. Clone this repository, then from the repo root:
78
+ node scripts\install-desktop.mjs
79
+
80
+ # 2. Restart DSH Desktop — the plugin loads with the next Cordis composition
81
+ ```
82
+
83
+ The script creates a no-space junction to the repo and runs the desktop app's own `dsh plugin add` through it (pnpm splits `add` arguments on spaces, so a repository path containing spaces must go through the junction):
84
+
85
+ ```powershell
86
+ # 0) Create a no-space junction to the repository (needed when the path contains spaces)
87
+ mklink /J "C:\code_repos\dsh-rules" "C:\code_repos\dsh rules plugin"
88
+
89
+ # 1) Install via the desktop's own dsh command (through the junction path)
90
+ & "C:\Program Files\DSH Desktop\DSH Desktop.exe" --expose-internals `
91
+ "C:\Program Files\DSH Desktop\resources\app.asar.unpacked\lib\desktop-cli.js" `
92
+ plugin --profile desktop add "C:\code_repos\dsh-rules"
93
+ ```
94
+
95
+ Per-profile configuration (optional): the plugin loads with its code defaults; to customize, override the entry's `config` in `<profile>/cordis.patch.yml`:
96
+
97
+ ```yaml
98
+ - id: dsh-rules
99
+ name: dsh-rules
100
+ config:
101
+ includeClaudeSections: true
102
+ projectRootMarkers: [".git", ".dsh"]
103
+ ```
104
+
105
+ **Uninstall**: `node scripts\install-desktop.mjs --uninstall` (or `dsh plugin --profile desktop remove dsh-rules`), then restart the app. Installing/uninstalling never touches the DSH installation directory (`resources\app.asar.unpacked`) — only profile configuration, fully reversible.
106
+
107
+ ## Rule format
108
+
109
+ ### Source A: rule files (`.dsh/rules/*.md` and `~/.dsh/rules/*.md`)
110
+
111
+ ```markdown
112
+ ---
113
+ path:
114
+ - "src/**/*.ts"
115
+ - "!src/**/*.test.ts"
116
+ ---
117
+ Rule body (markdown, injected verbatim when active — any prompt content works)
118
+ ```
119
+
120
+ | Frontmatter field | Description |
121
+ | --- | --- |
122
+ | `path` | String or list of globs, relative to the project root, `/` separators; `!` prefixes mark exclusion patterns. **Absent or empty = always-active global rule** (active for any session in the workspace). |
123
+ | `name` | Optional; rule identity (used for same-name deduplication). Defaults to the file name without `.md`. |
124
+
125
+ ### Source B: `# Path:` sections (requires `includeClaudeSections: true`)
126
+
127
+ Parses `# Path: <globs…>` headings out of `AGENTS.md` / `CLAUDE.md` (including `.local.md` variants and `~/.dsh/AGENTS.md`):
128
+
129
+ ```markdown
130
+ # Project notes (content before the first heading is handled by the built-in agent-instructions baseline, not by this plugin)
131
+
132
+ # Path: src/**/*.ts, scripts/**
133
+ This section activates only when a file under src/**/*.ts or scripts/ is touched
134
+ ```
135
+
136
+ - Each `# Path:` heading starts a rule that runs until the next heading (or end of file).
137
+ - Globs may be comma- or space-separated.
138
+ - Content before the first `# Path:` heading is intentionally **not** injected by this plugin — DSH's built-in `agent-instructions` already injects the full AGENTS.md/CLAUDE.md baseline.
139
+
140
+ ### Precedence & deduplication
141
+
142
+ Project rules (rank 100) > user rules (rank 200) > `# Path:` sections (rank 300). Same-name rules keep the highest-priority entry; rendering order is (rank, name) — deterministic across steps.
143
+
144
+ ## Configuration
145
+
146
+ | Option | Default | Description |
147
+ | --- | --- | --- |
148
+ | `dshHome` | `$DSH_HOME` / `~/.dsh` | Root for user rules and `~/.dsh/AGENTS.md` |
149
+ | `projectRootMarkers` | `[".git"]` | Marker files/dirs used to find the project root by walking up |
150
+ | `ruleDirNames` | `[".dsh/rules"]` | Rule directories inside the project (relative to the project root, multiple allowed) |
151
+ | `includeUserRules` | `true` | Enable `~/.dsh/rules/*.md` |
152
+ | `includeClaudeSections` | `false` | Parse `# Path:` sections |
153
+ | `instructionFileCandidates` | `["AGENTS.md", "CLAUDE.md"]` | Candidate file names for `# Path:` sections |
154
+ | `localInstructionFileCandidates` | `["AGENTS.local.md", "CLAUDE.local.md"]` | Per-directory candidate file names |
155
+ | `maxBytes` | `32768` | Per-injection render budget (UTF-8 bytes); `<= 0` disables the plugin |
156
+ | `maxSourceBytes` | `1048576` | Per-rule source size cap; larger files are skipped |
157
+ | `maxTouchedPaths` | `512` | Touched-path cap per session (FIFO eviction) |
158
+
159
+ ## Discoverability
160
+
161
+ This plugin is discoverable through the GitHub [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic — the channel recommended by the [DeepSeek Harness README](https://github.com/deepseek-ai/deepseek-harness) ("Community and support": *Add the `dsh-plugin` topic to your plugin repository for discoverability*). Community plugin lists and marketplaces (e.g. awesome-dsh-plugin, dsh-plugin-marketplace) scan that topic to pick up new plugins; the tag can be viewed/edited in the repository's About section.
162
+
163
+ ## Limitations
164
+
165
+ - Only files **inside the project root** can activate rules; reads outside the root never trigger (avoids `../` false positives).
166
+ - The touched-path set is in-memory: after resuming a session, rules re-activate as the agent re-reads files (the previously matched list is restored from the log).
167
+ - Deployments with `includeRuntimeContext: false` are unaffected — this plugin injects its own message and does not depend on the runtime-context snapshot.
168
+ - Rules are injected as "superseding snapshot" messages; the session log retains historical snapshots, but each snapshot is the complete current set and the model follows the latest one.
169
+
170
+ ## Development
171
+
172
+ ```powershell
173
+ pnpm install
174
+ pnpm test # node --test: parsing / glob matching / precedence / budget / determinism / fs fallback
175
+ ```
176
+
177
+ Layout:
178
+
179
+ - `lib/index.js` — plugin entry (`name` / `Config` / `apply`): `fs/observed` touch tracking, `agent/pre-step` injection, `agent/disposed` cleanup.
180
+ - `lib/rules.js` — pure logic: frontmatter and `# Path:` parsing, glob compilation/matching, precedence merging, budget rendering.
181
+ - `lib/fs.js` — versioned discovery/reads: harness `fs` service first, Node fallback.
182
+ - `test/rules.test.mjs` — unit tests.
183
+ - `examples/.dsh/rules/` — sample rules (copy into your project to get started).
184
+ - `fixtures/demo-project/` — a ready-made project for trying the plugin out.
185
+
186
+ ## License
187
+
188
+ [MIT](LICENSE)
package/README.zh.md ADDED
@@ -0,0 +1,188 @@
1
+ # dsh-rules
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![DSH Plugin topic](https://img.shields.io/badge/topic-dsh--plugin-blue)](https://github.com/topics/dsh-plugin)
5
+
6
+ [English](./README.md) | **中文**
7
+
8
+ 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的 rules 插件:**通过 glob 匹配文件路径,激活对应的提示词 / markdown 文档**,对标 Claude Code 的 `rules.md` / `# Path:` 规则机制。每条规则声明 glob 模式;当 agent **读取或编辑**了匹配文件时规则激活,其内容(任意提示词或 markdown 文档)作为一条"取代旧快照"的 `<rules>` 用户消息注入对话。
9
+
10
+ > 适用于**所有 DSH 部署**:desktop / web / tui / headless / 自定义 profile——插件本身没有任何 desktop 专属依赖。
11
+
12
+ ## 目录
13
+
14
+ - [特性](#特性)
15
+ - [工作原理](#工作原理)
16
+ - [安装](#安装)
17
+ - [规则格式](#规则格式)
18
+ - [配置项](#配置项)
19
+ - [发现与收录](#发现与收录)
20
+ - [已知限制](#已知限制)
21
+ - [开发](#开发)
22
+ - [许可证](#许可证)
23
+
24
+ ## 特性
25
+
26
+ - **glob 激活** —— 按 agent 触碰的文件逐个激活规则:`**`、`*`、`?`、`{a,b}`、`[abc]`、`!` 取反(picomatch)。
27
+ - **Claude Code 兼容** —— 既支持规则文件(`.dsh/rules/*.md`),也支持 `CLAUDE.md` / `AGENTS.md` 内的 `# Path:` 段落。
28
+ - **可见且持久** —— 激活的规则以用户消息注入,UI 可见、会话日志持久化;每个快照取代更早快照,模型始终看到最新集合。
29
+ - **预算可控** —— 字节预算渲染(默认 32 KB):先丢弃低优先级规则,再截断最后一条;正文转义,无法逃逸框架标签。
30
+ - **恢复友好** —— 恢复会话时从日志恢复最近一次快照与已匹配文件列表,避免重复注入。
31
+ - **按会话跟踪** —— 每个 agent/会话独立跟踪触碰文件(含子代理);无 `path:` 的全局规则恒激活。
32
+
33
+ ## 工作原理
34
+
35
+ ```
36
+ 工作区
37
+ .dsh/rules/*.md ← 规则定义(frontmatter 声明 glob)
38
+ ~/.dsh/rules/*.md ← 用户级规则(可选)
39
+ CLAUDE.md ← 可选:# Path: 段落(Claude Code 兼容)
40
+
41
+ agent 读取/编辑文件(fs/observed)→ 记录每会话触碰路径
42
+ ↓ 每个 step(agent/pre-step)
43
+ 按 glob 匹配触碰路径 → 收集激活规则 → 渲染 <rules> 快照注入对话
44
+ ```
45
+
46
+ - **注入点**:`agent/pre-step` 瀑布监听器追加一条 `<rules>` 框架的用户消息;快照文本变化时才注入新消息。
47
+ - **发现与缓存**:每个 step 重新探测规则源并做版本缓存(`fs.stat().version`,Node 回退为 `mtimeMs:size`)——规则文件改动在下一步即生效。
48
+ - **读取**:优先 harness `fs` 服务(遵守沙箱约束);无 `fs` 服务时回退 Node 文件系统。
49
+
50
+ ## 安装
51
+
52
+ ### 通用安装(任意 DSH 部署)
53
+
54
+ 已发布到 npm registry —— `dsh plugin` 一步完成安装**并激活**:
55
+
56
+ ```powershell
57
+ # profile 名按你的部署调整:desktop / web / tui / headless
58
+ dsh plugin --profile desktop add dsh-rules
59
+ ```
60
+
61
+ 插件包声明了 `dsh.bundle.patch`,`dsh plugin add` 的 reconcile 步骤会自动把 `dsh-rules` 追加到 profile 的 `dsh.profile.bundles` 层列表——**无需手动编辑 cordis.patch.yml**。重启 DSH(桌面版重启应用;web/headless 重启进程),插件随下次 Cordis 组合加载。
62
+
63
+ 更新:`dsh plugin --profile desktop update dsh-rules`(或 remove 后 add)。
64
+
65
+ 从本地仓库安装(开发模式):
66
+
67
+ ```powershell
68
+ # 在仓库根目录执行——同样自动激活 bundle
69
+ dsh plugin --profile desktop add .
70
+ ```
71
+
72
+ > ⚠️ pnpm 会按空格拆分 `add` 参数,仓库**路径含空格**时必须通过无空格 junction 安装(见下)。
73
+
74
+ ### DSH Desktop(Windows)一键脚本
75
+
76
+ ```powershell
77
+ # 1. 克隆本仓库后,在仓库根目录执行:
78
+ node scripts\install-desktop.mjs
79
+
80
+ # 2. 重启 DSH Desktop(插件在下次启动时随 Cordis 组合加载)
81
+ ```
82
+
83
+ 脚本会创建指向仓库的无空格 junction,并通过它执行桌面自带的 `dsh plugin add`(pnpm 按空格拆分 `add` 参数,路径含空格时必须走 junction):
84
+
85
+ ```powershell
86
+ # 0) 为仓库创建无空格 junction(路径含空格时需要)
87
+ mklink /J "C:\code_repos\dsh-rules" "C:\code_repos\dsh rules plugin"
88
+
89
+ # 1) 用桌面自带的 dsh 命令把插件装进 profile(经 junction 路径)
90
+ & "C:\Program Files\DSH Desktop\DSH Desktop.exe" --expose-internals `
91
+ "C:\Program Files\DSH Desktop\resources\app.asar.unpacked\lib\desktop-cli.js" `
92
+ plugin --profile desktop add "C:\code_repos\dsh-rules"
93
+ ```
94
+
95
+ 按 profile 自定义配置(可选):插件按代码默认值加载;如需定制,在 `<profile>/cordis.patch.yml` 里以 id 定位覆盖该条目的 `config`:
96
+
97
+ ```yaml
98
+ - id: dsh-rules
99
+ name: dsh-rules
100
+ config:
101
+ includeClaudeSections: true
102
+ projectRootMarkers: [".git", ".dsh"]
103
+ ```
104
+
105
+ **卸载**:`node scripts\install-desktop.mjs --uninstall`(或 `dsh plugin --profile desktop remove dsh-rules`),再重启应用。安装/卸载均不修改 DSH 安装目录(`resources\app.asar.unpacked`),只动 profile 配置,可随时回滚。
106
+
107
+ ## 规则格式
108
+
109
+ ### 来源 A:规则文件(`.dsh/rules/*.md` 与 `~/.dsh/rules/*.md`)
110
+
111
+ ```markdown
112
+ ---
113
+ path:
114
+ - "src/**/*.ts"
115
+ - "!src/**/*.test.ts"
116
+ ---
117
+ 规则正文(markdown,激活时原样注入,可以是任意提示词内容)
118
+ ```
119
+
120
+ | frontmatter 字段 | 说明 |
121
+ | --- | --- |
122
+ | `path` | 字符串或字符串数组;glob 相对项目根、使用 `/` 分隔符;`!` 前缀为排除模式。**缺省或为空 = 全局常驻规则**(工作区任意会话都激活)。 |
123
+ | `name` | 可选;规则标识(用于同名规则去重),缺省取文件名(去掉 `.md`)。 |
124
+
125
+ ### 来源 B:`# Path:` 段落(需 `includeClaudeSections: true`)
126
+
127
+ 从 `AGENTS.md` / `CLAUDE.md`(含 `.local.md`,以及 `~/.dsh/AGENTS.md`)中解析 `# Path: <glob…>` 标题段落:
128
+
129
+ ```markdown
130
+ # 项目说明(此段之前的内容交给 agent-instructions 基线处理,本插件不注入)
131
+
132
+ # Path: src/**/*.ts, scripts/**
133
+ 本段仅在触碰 src 下 .ts 或 scripts 下文件时激活
134
+ ```
135
+
136
+ - 每个 `# Path:` 标题之后直到下一个标题(或文件尾)是一条规则。
137
+ - glob 支持逗号或空格分隔。
138
+ - 首个 `# Path:` 之前的内容**不**由本插件注入(DSH 内置的 `agent-instructions` 已负责注入 AGENTS.md/CLAUDE.md 基线全文)。
139
+
140
+ ### 优先级与去重
141
+
142
+ 项目规则(rank 100)> 用户规则(rank 200)> `# Path:` 段落(rank 300)。同名规则仅保留最高优先级者;渲染顺序按(rank, 名称)确定,保证跨 step 稳定。
143
+
144
+ ## 配置项
145
+
146
+ | 配置 | 默认 | 说明 |
147
+ | --- | --- | --- |
148
+ | `dshHome` | `$DSH_HOME` / `~/.dsh` | 用户级规则与 `~/.dsh/AGENTS.md` 所在根目录 |
149
+ | `projectRootMarkers` | `[".git"]` | 向上寻找项目根的标记文件/目录 |
150
+ | `ruleDirNames` | `[".dsh/rules"]` | 项目内规则目录(相对项目根,可多个) |
151
+ | `includeUserRules` | `true` | 是否启用 `~/.dsh/rules/*.md` |
152
+ | `includeClaudeSections` | `false` | 是否解析 `# Path:` 段落 |
153
+ | `instructionFileCandidates` | `["AGENTS.md", "CLAUDE.md"]` | `# Path:` 段落候选文件名 |
154
+ | `localInstructionFileCandidates` | `["AGENTS.local.md", "CLAUDE.local.md"]` | 目录级候选文件名 |
155
+ | `maxBytes` | `32768` | 每次注入的渲染预算(UTF-8 字节),`<= 0` 关闭插件 |
156
+ | `maxSourceBytes` | `1048576` | 单条规则源文件大小上限,超出跳过 |
157
+ | `maxTouchedPaths` | `512` | 每会话记录的触碰路径上限(FIFO 淘汰) |
158
+
159
+ ## 发现与收录
160
+
161
+ 本插件通过 GitHub [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic 被 DSH 生态发现——这是 [DeepSeek Harness 官方 README](https://github.com/deepseek-ai/deepseek-harness) "Community and support" 章节推荐的插件收录渠道(*Add the `dsh-plugin` topic to your plugin repository for discoverability*)。社区插件列表与市场(如 awesome-dsh-plugin、dsh-plugin-marketplace)据此自动扫描收录;仓库 About 栏可查看/添加该标签。
162
+
163
+ ## 已知限制
164
+
165
+ - 只有**项目根内**的文件能激活规则;读取项目外文件不触发(避免 `../` 误匹配)。
166
+ - 触碰集合是内存态:恢复会话后,规则随 agent 重新读取文件逐步重新激活(已匹配文件列表会从日志恢复)。
167
+ - `includeRuntimeContext: false` 的部署不受影响(本插件注入独立消息,不依赖运行时上下文快照)。
168
+ - 规则注入为"取代旧快照"的消息流,会话日志中会保留历史快照;每个快照本身是完整集合,模型以最新快照为准。
169
+
170
+ ## 开发
171
+
172
+ ```powershell
173
+ pnpm install
174
+ pnpm test # node --test:解析 / glob / 优先级 / 预算 / 确定性 / fs 回退
175
+ ```
176
+
177
+ 代码结构:
178
+
179
+ - `lib/index.js` — 插件入口(`name` / `Config` / `apply`):`fs/observed` 触碰跟踪、`agent/pre-step` 注入、`agent/disposed` 清理。
180
+ - `lib/rules.js` — 纯函数:frontmatter 与 `# Path:` 解析、glob 编译匹配、优先级合并、预算渲染。
181
+ - `lib/fs.js` — 版本化发现/读取:优先 harness `fs` 服务,缺失时回退 Node fs。
182
+ - `test/rules.test.mjs` — 单元测试。
183
+ - `examples/.dsh/rules/` — 示例规则(可直接复制到项目使用)。
184
+ - `fixtures/demo-project/` — 现成的体验项目。
185
+
186
+ ## 许可证
187
+
188
+ [MIT](LICENSE)
@@ -0,0 +1,20 @@
1
+ # dsh-rules bundle layer: register the glob-activated rules plugin
2
+ # (Claude Code rules.md style) into the host plane.
3
+ #
4
+ # This layer is applied by the profile loader because package.json declares
5
+ # `dsh.bundle.patch`; `dsh plugin --profile <name> add dsh-rules` therefore
6
+ # activates the plugin automatically — no manual cordis.patch.yml edits are
7
+ # needed in the target profile.
8
+ #
9
+ # Per-profile customization (e.g. `includeClaudeSections: true`, extra
10
+ # `projectRootMarkers`) belongs in the profile's own cordis.patch.yml as an
11
+ # id-targeted override of this entry's `config`:
12
+ #
13
+ # - id: dsh-rules
14
+ # name: dsh-rules
15
+ # config:
16
+ # includeClaudeSections: true
17
+ # projectRootMarkers: [".git", ".dsh"]
18
+ - insert:
19
+ - id: dsh-rules
20
+ name: dsh-rules
package/lib/fs.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Filesystem helpers for the dsh-rules plugin.
3
+ *
4
+ * Reads prefer the harness `fs` service (which respects containment and
5
+ * produces stable `version` identities) and fall back to Node's own
6
+ * filesystem when no `fs` service is mounted. All discovery is
7
+ * cancellation-aware through `signal`.
8
+ *
9
+ * @module dsh-rules/fs
10
+ */
11
+ import { readdir, readFile, stat } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+
14
+ /** Normalize a path to POSIX separators for glob matching. */
15
+ function toPosix(path) {
16
+ return path.split(sep).join("/");
17
+ }
18
+
19
+ /**
20
+ * Walk up from `cwd` to the first directory containing any project-root
21
+ * marker (e.g. `.git`); fall back to `cwd` itself.
22
+ * @param cwd - absolute session working directory.
23
+ * @param markers - marker file/directory names that identify a project root.
24
+ * @param fileSystem - optional harness `fs` service.
25
+ * @param signal - cancellation for provider probes.
26
+ * @returns the absolute project root.
27
+ */
28
+ export async function findProjectRoot(cwd, markers, fileSystem, signal) {
29
+ let current = resolve(cwd);
30
+ while (true) {
31
+ for (const marker of markers) {
32
+ if (await pathExists(join(current, marker), fileSystem, signal)) return current;
33
+ }
34
+ const parent = dirname(current);
35
+ if (parent === current) return resolve(cwd);
36
+ current = parent;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Return the project-root-relative POSIX path of `path`, or `undefined` when
42
+ * the path lies outside the project root (such files never activate rules).
43
+ * @param projectRoot - absolute project root.
44
+ * @param path - absolute path to relativize.
45
+ * @returns relative POSIX path, or `undefined` when outside the root.
46
+ */
47
+ export function posixRelative(projectRoot, path) {
48
+ const relativePath = relative(resolve(projectRoot), resolve(path));
49
+ if (relativePath.length === 0) return ".";
50
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) return void 0;
51
+ return toPosix(relativePath);
52
+ }
53
+
54
+ /**
55
+ * Probe one rule-source path (a file) for current metadata.
56
+ * @param path - absolute path to probe.
57
+ * @param fileSystem - optional harness `fs` service.
58
+ * @param signal - cancellation for provider probes.
59
+ * @returns present metadata (version identity plus optional size), confirmed
60
+ * absence, or temporary unavailability.
61
+ */
62
+ export async function statRuleFile(path, fileSystem, signal) {
63
+ signal?.throwIfAborted();
64
+ if (fileSystem !== void 0) {
65
+ try {
66
+ const target = await fileSystem.resolve(path, signalOptions(signal));
67
+ signal?.throwIfAborted();
68
+ const info = await fileSystem.stat(target, signal);
69
+ signal?.throwIfAborted();
70
+ if (info === void 0 || info.type !== "file") return { kind: "absent" };
71
+ return {
72
+ kind: "present",
73
+ version: info.version,
74
+ ...info.size === void 0 ? {} : { size: info.size }
75
+ };
76
+ } catch (error) {
77
+ signal?.throwIfAborted();
78
+ return isAbsentError(error) ? { kind: "absent" } : { kind: "unavailable" };
79
+ }
80
+ }
81
+ try {
82
+ const info = await stat(path, { signal });
83
+ signal?.throwIfAborted();
84
+ if (!info.isFile()) return { kind: "absent" };
85
+ return {
86
+ kind: "present",
87
+ version: nodeVersionSignature(info),
88
+ size: info.size
89
+ };
90
+ } catch (error) {
91
+ signal?.throwIfAborted();
92
+ return isAbsentError(error) ? { kind: "absent" } : { kind: "unavailable" };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Read one rule-source file's full text under a source byte cap.
98
+ * @param path - absolute path to read.
99
+ * @param fileSystem - optional harness `fs` service.
100
+ * @param signal - cancellation for provider reads.
101
+ * @param maxSourceBytes - maximum accepted UTF-8 bytes; larger files are skipped.
102
+ * @returns the file text, or `undefined` when absent, unreadable, or oversized.
103
+ */
104
+ export async function readRuleText(path, fileSystem, signal, maxSourceBytes) {
105
+ signal?.throwIfAborted();
106
+ if (fileSystem !== void 0) {
107
+ try {
108
+ const target = await fileSystem.resolve(path, signalOptions(signal));
109
+ signal?.throwIfAborted();
110
+ return await fileSystem.readText(target, signal);
111
+ } catch (error) {
112
+ signal?.throwIfAborted();
113
+ return void 0;
114
+ }
115
+ }
116
+ try {
117
+ const info = await stat(path, { signal });
118
+ signal?.throwIfAborted();
119
+ if (!info.isFile()) return void 0;
120
+ if (info.size > maxSourceBytes) return void 0;
121
+ return await readFile(path, { encoding: "utf8", signal });
122
+ } catch (error) {
123
+ signal?.throwIfAborted();
124
+ return void 0;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * List one rule-source directory's entries.
130
+ * @param dir - absolute directory path.
131
+ * @param fileSystem - optional harness `fs` service.
132
+ * @param signal - cancellation for provider probes.
133
+ * @returns entry descriptors, or `undefined` when the directory is absent.
134
+ */
135
+ export async function listRuleDirEntries(dir, fileSystem, signal) {
136
+ signal?.throwIfAborted();
137
+ if (fileSystem !== void 0) {
138
+ try {
139
+ const target = await fileSystem.resolve(dir, signalOptions(signal));
140
+ signal?.throwIfAborted();
141
+ return (await fileSystem.listDir(target, signal)).map((entry) => ({
142
+ name: entry.name,
143
+ type: entry.type
144
+ }));
145
+ } catch (error) {
146
+ signal?.throwIfAborted();
147
+ return isAbsentError(error) ? void 0 : null;
148
+ }
149
+ }
150
+ try {
151
+ const entries = await readdir(dir, { withFileTypes: true, encoding: "utf8" });
152
+ return entries.map((entry) => ({
153
+ name: entry.name,
154
+ type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other"
155
+ }));
156
+ } catch (error) {
157
+ signal?.throwIfAborted();
158
+ return isAbsentError(error) ? void 0 : null;
159
+ }
160
+ }
161
+
162
+ function signalOptions(signal) {
163
+ return signal === void 0 ? void 0 : { signal };
164
+ }
165
+
166
+ /** Test one path for existence through the provider or the host filesystem. */
167
+ async function pathExists(path, fileSystem, signal) {
168
+ if (fileSystem !== void 0) {
169
+ try {
170
+ const target = await fileSystem.resolve(path, signalOptions(signal));
171
+ signal?.throwIfAborted();
172
+ const info = await fileSystem.stat(target, signal);
173
+ signal?.throwIfAborted();
174
+ return info !== void 0;
175
+ } catch (error) {
176
+ signal?.throwIfAborted();
177
+ return false;
178
+ }
179
+ }
180
+ try {
181
+ await stat(path, signalOptions(signal));
182
+ return true;
183
+ } catch (error) {
184
+ signal?.throwIfAborted();
185
+ return false;
186
+ }
187
+ }
188
+
189
+ function nodeVersionSignature(info) {
190
+ return `${info.mtimeMs}:${info.size}`;
191
+ }
192
+
193
+ function isAbsentError(error) {
194
+ return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "FS_NOT_FOUND" || error.code === "FS_NOT_DIRECTORY");
195
+ }