dsh-vscode-mode 0.1.48 → 0.1.49

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/src/rules.ts ADDED
@@ -0,0 +1,528 @@
1
+ /**
2
+ * dsh-vscode-mode host — 规则管理(Codebuddy/Cursor 式 .mdc 规则)。
3
+ * - 存储:用户规则 ~/.dsh/rules/*.mdc;项目规则 <工作区>/.dsh/rules/*.mdc(随仓库共享)。
4
+ * - 格式:frontmatter(description / alwaysApply / globs / enabled)+ markdown 正文;
5
+ * 类型映射 总是=always / 自动=auto(globs) / 手动=manual(仅索引);enabled 为本插件扩展字段,缺省 true。
6
+ * - 生效:host 注册 systemPrompt.section(order 400),每次装配同步读取(mtime 缓存),
7
+ * 项目规则按 AssembleContext.agent.id → 会话 cwd 注入;旧版 DSH 缺服务时优雅降级。
8
+ * --region 划分:常量 / frontmatter 解析(纯)/ 文件名与开关(纯)/ 注入渲染(纯)/ IO / systemPrompt 装配
9
+ * 作者 ddj 2026年09月03号
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
12
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
13
+ import { Buffer } from 'node:buffer'
14
+ import { join } from 'node:path'
15
+ import { dshHome } from './paths.js'
16
+ import type { RuleInfo, RuleProject, RuleRefInput, RuleScope, RuleSaveInput } from './shared/rules.js'
17
+ import type { Ctx } from './store.js'
18
+
19
+ // --region 常量与目录定位
20
+ /** 规则文件名白名单:字母数字开头,仅字母数字点横下划线,.mdc 后缀(天然拒绝路径分隔符)。 */
21
+ export const RULE_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.mdc$/
22
+ /** Windows 保留设备名(防意外创建系统设备文件)。 */
23
+ const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
24
+ /** 单目录最多加载的规则文件数(防失控目录拖垮装配)。 */
25
+ const RULE_DIR_CAP = 200
26
+ /** 注入时单条规则正文字节上限(超出截断)。 */
27
+ const RULE_SINGLE_CAP = 16 * 1024
28
+ /** 注入时单个作用域(用户/项目)总字节预算。 */
29
+ const RULE_SCOPE_CAP = 64 * 1024
30
+ /** systemPrompt section 名(同层唯一)。 */
31
+ const SECTION_NAME = 'dsh-vscode-mode:rules'
32
+ /** section 顺序:persona(0) 与 PLAN_POLICY(500) 之间,规则先于计划策略被读到。 */
33
+ const SECTION_ORDER = 400
34
+
35
+ /**
36
+ * 用户规则目录(~/.dsh/rules)。
37
+ * @author ddj 2026年09月03号
38
+ * @param home DSH home(缺省自动解析)
39
+ * @returns 绝对路径
40
+ */
41
+ export function userRulesDir(home = dshHome()): string {
42
+ return join(home, 'rules')
43
+ }
44
+
45
+ /**
46
+ * 项目规则目录(<工作区>/.dsh/rules)。
47
+ * @author ddj 2026年09月03号
48
+ * @param workspacePath 工作区绝对路径
49
+ * @returns 绝对路径
50
+ */
51
+ export function projectRulesDir(workspacePath: string): string {
52
+ return join(workspacePath, '.dsh', 'rules')
53
+ }
54
+ // --endregion
55
+
56
+ // --region frontmatter 解析(纯函数)
57
+ /** parseRuleMdc 的产出:frontmatter 字段 + 正文 + 可选解析错误。 */
58
+ export interface ParsedRule {
59
+ description: string
60
+ alwaysApply: boolean
61
+ globs: string[]
62
+ enabled: boolean
63
+ body: string
64
+ error?: string
65
+ }
66
+
67
+ /**
68
+ * 去除值两侧成对引号。
69
+ * @author ddj 2026年09月03号
70
+ * @param raw 原始值
71
+ * @returns 去引号后的值
72
+ */
73
+ function stripQuotes(raw: string): string {
74
+ const value = raw.trim()
75
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
76
+ return value.slice(1, -1)
77
+ }
78
+ return value
79
+ }
80
+
81
+ /**
82
+ * 解析 globs 值:内联逗号分隔串,或空值后接 YAML 短横列表。
83
+ * @author ddj 2026年09月03号
84
+ * @param value 内联值(可为空)
85
+ * @param listLines 后续短横列表行内容(去掉 `- ` 前缀)
86
+ * @returns glob 数组
87
+ */
88
+ function parseGlobs(value: string, listLines: string[]): string[] {
89
+ const inline = stripQuotes(value)
90
+ const items = inline ? inline.split(',') : listLines
91
+ return items.map((item) => stripQuotes(item)).filter(Boolean)
92
+ }
93
+
94
+ /**
95
+ * 容错解析一条 .mdc 规则:首行 `---` 且 100 行内有闭合 `---` 才视为 frontmatter,
96
+ * 否则整个文本当正文(类型 manual、enabled=true)。解析永不抛错。
97
+ * @author ddj 2026年09月03号
98
+ * @param text 文件全文
99
+ * @returns 解析结果
100
+ */
101
+ export function parseRuleMdc(text: string): ParsedRule {
102
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
103
+ if (lines[0]?.trim() !== '---') return { description: '', alwaysApply: false, globs: [], enabled: true, body: text }
104
+ let close = -1
105
+ for (let i = 1; i < lines.length && i <= 101; i++) {
106
+ if (lines[i].trim() === '---') { close = i; break }
107
+ }
108
+ if (close < 0) return { description: '', alwaysApply: false, globs: [], enabled: true, body: text }
109
+ const result: ParsedRule = { description: '', alwaysApply: false, globs: [], enabled: true, body: lines.slice(close + 1).join('\n') }
110
+ for (let i = 1; i < close; i++) {
111
+ const match = /^([A-Za-z_-]+)[ \t]*:[ \t]?(.*)$/.exec(lines[i])
112
+ if (!match) continue
113
+ const [, key, raw] = match
114
+ if (key === 'description') result.description = stripQuotes(raw)
115
+ else if (key === 'alwaysApply') result.alwaysApply = stripQuotes(raw).toLowerCase() === 'true'
116
+ else if (key === 'enabled') result.enabled = stripQuotes(raw).toLowerCase() !== 'false'
117
+ else if (key === 'globs') {
118
+ const listLines: string[] = []
119
+ for (let j = i + 1; j < close; j++) {
120
+ const item = /^[ \t]*-[ \t]*(.+)$/.exec(lines[j])
121
+ if (!item) break
122
+ listLines.push(item[1])
123
+ }
124
+ result.globs = parseGlobs(raw, listLines)
125
+ }
126
+ }
127
+ return result
128
+ }
129
+
130
+ /**
131
+ * 由 frontmatter 推导规则类型:总是 / 自动(有 globs)/ 手动。
132
+ * @author ddj 2026年09月03号
133
+ * @param parsed 解析结果
134
+ * @returns 规则类型
135
+ */
136
+ export function ruleTypeOf(parsed: ParsedRule): 'always' | 'auto' | 'manual' {
137
+ if (parsed.alwaysApply) return 'always'
138
+ if (parsed.globs.length) return 'auto'
139
+ return 'manual'
140
+ }
141
+ // --endregion
142
+
143
+ // --region 文件名与开关改写(纯函数)
144
+ /**
145
+ * 校验规则文件名(白名单 + Windows 保留名拒绝)。
146
+ * @author ddj 2026年09月03号
147
+ * @param name 文件名(含后缀)
148
+ * @returns 错误文案;null=合法
149
+ */
150
+ export function validateRuleFile(name: string): string | null {
151
+ if (!RULE_FILE_RE.test(name)) return '文件名不合法:仅允许字母数字开头,含字母/数字/点/横线/下划线,.mdc 后缀'
152
+ if (RESERVED_NAME_RE.test(name)) return '文件名是 Windows 保留设备名,不允许'
153
+ return null
154
+ }
155
+
156
+ /**
157
+ * 只改写 frontmatter 的 enabled 行(保留 BOM、CRLF 与其余内容不动);无规则 frontmatter 返回 null。
158
+ * @author ddj 2026年09月03号
159
+ * @param text 文件全文
160
+ * @param enabled 目标开关状态
161
+ * @returns 改写后的全文;null=无法安全改写
162
+ */
163
+ export function toggleEnabledLine(text: string, enabled: boolean): string | null {
164
+ const hasBom = text.startsWith('\uFEFF')
165
+ const rest = hasBom ? text.slice(1) : text
166
+ const eol = rest.includes('\r\n') ? '\r\n' : '\n'
167
+ const lines = rest.split(/\r?\n/)
168
+ if (lines[0]?.trim() !== '---') return null
169
+ let close = -1
170
+ for (let i = 1; i < lines.length && i <= 101; i++) {
171
+ if (lines[i].trim() === '---') { close = i; break }
172
+ }
173
+ if (close < 0) return null
174
+ const line = 'enabled: ' + (enabled ? 'true' : 'false')
175
+ let replaced = false
176
+ for (let i = 1; i < close; i++) {
177
+ if (/^[ \t]*enabled[ \t]*:/.test(lines[i])) { lines[i] = line; replaced = true; break }
178
+ }
179
+ if (!replaced) lines.splice(1, 0, line)
180
+ return (hasBom ? '\uFEFF' : '') + lines.join(eol)
181
+ }
182
+ // --endregion
183
+
184
+ // --region 注入渲染(纯函数)
185
+ /** 一条已加载规则:元信息 + 正文(注入渲染输入)。 */
186
+ export interface LoadedRule {
187
+ info: RuleInfo
188
+ body: string
189
+ }
190
+
191
+ /**
192
+ * 渲染单条规则块:标题 + 描述 + 截断后的正文;auto 类型追加 glob 条件说明。
193
+ * @author ddj 2026年09月03号
194
+ * @param rule 已加载规则
195
+ * @returns 文本块(含尾空行)
196
+ */
197
+ function ruleBlock(rule: LoadedRule): string {
198
+ let body = rule.body
199
+ if (body.length > RULE_SINGLE_CAP) body = body.slice(0, RULE_SINGLE_CAP) + '\n…(规则正文超长已截断)'
200
+ const condition = rule.info.type === 'auto' && rule.info.globs.length
201
+ ? '(当处理匹配 ' + rule.info.globs.join(', ') + ' 的文件时应用)'
202
+ : ''
203
+ const desc = rule.info.description ? '\n' + rule.info.description : ''
204
+ return '#### ' + rule.info.file + condition + desc + '\n' + body + '\n'
205
+ }
206
+
207
+ /**
208
+ * 渲染一个作用域(用户/项目)的注入段:总是全文 / 自动带条件 / 手动仅索引行。
209
+ * @author ddj 2026年09月03号
210
+ * @param title 段标题
211
+ * @param rules 已加载规则
212
+ * @returns 段文本;无启用规则时返回空串
213
+ */
214
+ function renderScope(title: string, rules: LoadedRule[]): string {
215
+ const enabled = rules.filter((rule) => rule.info.enabled && !rule.info.error)
216
+ const always = enabled.filter((rule) => rule.info.type === 'always')
217
+ const auto = enabled.filter((rule) => rule.info.type === 'auto')
218
+ const manual = enabled.filter((rule) => rule.info.type === 'manual')
219
+ if (!always.length && !auto.length && !manual.length) return ''
220
+ const out: string[] = ['## ' + title, '']
221
+ let budget = RULE_SCOPE_CAP
222
+ let omitted = false
223
+ const push = (block: string): void => {
224
+ if (block.length > budget) { omitted = true; return }
225
+ budget -= block.length
226
+ out.push(block)
227
+ }
228
+ if (always.length) {
229
+ out.push('### 总是生效', '')
230
+ for (const rule of always) push(ruleBlock(rule))
231
+ }
232
+ if (auto.length) {
233
+ out.push('### 按文件匹配生效', '')
234
+ for (const rule of auto) push(ruleBlock(rule))
235
+ }
236
+ if (manual.length) {
237
+ out.push('### 可按需读取(未自动生效;需要时读取对应文件)', '')
238
+ for (const rule of manual) {
239
+ const desc = rule.info.description ? ' — ' + rule.info.description : ''
240
+ push('- ' + rule.info.file + desc + '(路径: ' + rule.info.absPath + ')\n')
241
+ }
242
+ }
243
+ if (omitted) out.push('', '(部分规则因总长度预算被省略,请精简规则文件)')
244
+ return out.join('\n') + '\n'
245
+ }
246
+
247
+ /**
248
+ * 渲染规则注入 section 全文:用户规则段 + 项目规则段;两者皆空返回空串(空 section 无害)。
249
+ * @author ddj 2026年09月03号
250
+ * @param user 用户规则
251
+ * @param project 当前工作区项目规则
252
+ * @param workspacePath 项目段标注的工作区路径(缺省跳过项目段)
253
+ * @returns 注入文本
254
+ */
255
+ export function renderRulesSection(user: LoadedRule[], project: LoadedRule[], workspacePath?: string): string {
256
+ const userText = renderScope('用户规则(用户配置,必须遵守)', user)
257
+ const projectText = workspacePath ? renderScope('项目规则(工作区 ' + workspacePath + ',必须遵守)', project) : ''
258
+ return [userText, projectText].filter(Boolean).join('\n')
259
+ }
260
+ // --endregion
261
+
262
+ // --region IO(列表 / 读 / 存 / 删 / 开关)
263
+ /** 单目录规则元信息列表(仅 *.mdc,最多 RULE_DIR_CAP 个;目录缺失返回空)。 */
264
+ async function listRulesDir(dir: string, scope: RuleScope): Promise<RuleInfo[]> {
265
+ const names = await readdir(dir).catch(() => [] as string[])
266
+ const mdcs = names.filter((name) => name.endsWith('.mdc')).sort().slice(0, RULE_DIR_CAP)
267
+ const out: RuleInfo[] = []
268
+ for (const file of mdcs) {
269
+ const absPath = join(dir, file)
270
+ const info = await stat(absPath).catch(() => null)
271
+ if (!info || !info.isFile()) continue
272
+ const parsed = parseRuleMdc(await readFile(absPath, 'utf8').catch(() => ''))
273
+ out.push(toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs))
274
+ }
275
+ return out
276
+ }
277
+
278
+ /** 已注册 workspace 列表(项目 Tab 与写入校验共用)。 */
279
+ function workspaceList(ctx: Ctx): Array<{ path: string; title?: string }> {
280
+ const list = ctx.get('workspaceRegistry')?.list?.() ?? []
281
+ return (list as Array<{ path?: string; title?: string }>).filter(
282
+ (item): item is { path: string; title?: string } => Boolean(item) && typeof item.path === 'string' && item.path !== '',
283
+ )
284
+ }
285
+
286
+ /** 用户显式 GUI 写操作的策略:danger-full-access(照抄 mcpProject.fullPolicy)。 */
287
+ function fullPolicy(ctx: Ctx): unknown {
288
+ const svc = ctx.get('sandboxPolicy')
289
+ if (!svc || typeof svc.resolve !== 'function') return undefined
290
+ return svc.resolve({ mode: 'danger-full-access' })
291
+ }
292
+
293
+ /** 校验项目作用域的 workspacePath 已注册为 DSH workspace(防 RPC 写任意目录)。 */
294
+ function requireWorkspace(ctx: Ctx, workspacePath: string | undefined): { path: string; title?: string } {
295
+ const workspace = workspaceList(ctx).find((item) => item.path === workspacePath)
296
+ if (!workspace) throw new Error('项目未注册为 DSH workspace,不能管理项目规则')
297
+ return workspace as { path: string; title?: string }
298
+ }
299
+
300
+ /** 按作用域解析规则目录(project 先过 requireWorkspace)。 */
301
+ function dirOf(ctx: Ctx, scope: RuleScope, workspacePath?: string): string {
302
+ if (scope === 'project') return projectRulesDir(requireWorkspace(ctx, workspacePath).path)
303
+ return userRulesDir()
304
+ }
305
+
306
+ /**
307
+ * 规则总列表:用户规则 + 各已注册工作区的项目规则。
308
+ * @author ddj 2026年09月03号
309
+ * @param ctx DSH 上下文
310
+ * @returns rules.list 载荷
311
+ */
312
+ export async function rulesList(ctx: Ctx): Promise<{ user: RuleInfo[]; projects: RuleProject[] }> {
313
+ const user = await listRulesDir(userRulesDir(), 'user')
314
+ const projects: RuleProject[] = []
315
+ for (const workspace of workspaceList(ctx)) {
316
+ const dir = projectRulesDir(workspace.path)
317
+ const exists = existsSync(dir)
318
+ const rules = exists ? await listRulesDir(dir, 'project') : []
319
+ projects.push({ workspacePath: workspace.path, title: workspace.title ?? '', rules, missingDir: exists ? undefined : true })
320
+ }
321
+ return { user, projects }
322
+ }
323
+
324
+ /**
325
+ * 读取一条规则全文(编辑器回填用)。
326
+ * @author ddj 2026年09月03号
327
+ * @param ctx DSH 上下文
328
+ * @param ref 作用域 + 文件名(+ 项目工作区)
329
+ * @returns 文件全文
330
+ */
331
+ export async function rulesRead(ctx: Ctx, ref: RuleRefInput): Promise<string> {
332
+ const dir = dirOf(ctx, ref.scope, ref.workspacePath)
333
+ return readFile(join(dir, ref.file), 'utf8')
334
+ }
335
+
336
+ /**
337
+ * 组装规则元信息视图(rulesSave / rulesToggle / 注入缓存共用)。
338
+ * @author ddj 2026年09月03号
339
+ * @param scope 作用域
340
+ * @param file 文件名
341
+ * @param absPath 绝对路径
342
+ * @param parsed 解析结果
343
+ * @param size 字节数
344
+ * @param mtime 修改时间毫秒
345
+ * @returns 规则元信息
346
+ */
347
+ function toRuleInfo(scope: RuleScope, file: string, absPath: string, parsed: ParsedRule, size: number, mtime: number): RuleInfo {
348
+ return {
349
+ scope, file, absPath,
350
+ relHint: scope === 'project' ? '.dsh/rules/' : 'rules/',
351
+ description: parsed.description, type: ruleTypeOf(parsed), globs: parsed.globs, enabled: parsed.enabled,
352
+ size, mtime, error: parsed.error,
353
+ }
354
+ }
355
+
356
+ /**
357
+ * 保存(新建或覆盖)一条规则:project 作用域写盘走 ctx fs + danger-full-access(镜像 mcpProject)。
358
+ * @author ddj 2026年09月03号
359
+ * @param ctx DSH 上下文
360
+ * @param input 保存入参
361
+ * @returns 保存后的规则元信息
362
+ */
363
+ export async function rulesSave(ctx: Ctx, input: RuleSaveInput): Promise<RuleInfo> {
364
+ const invalid = validateRuleFile(input.file)
365
+ if (invalid) throw new Error(invalid)
366
+ const dir = dirOf(ctx, input.scope, input.workspacePath)
367
+ await mkdir(dir, { recursive: true })
368
+ const absPath = join(dir, input.file)
369
+ const content = String(input.content ?? '')
370
+ if (input.scope === 'project') {
371
+ const fs = ctx.get('fs')
372
+ if (!fs) throw new Error('缺少 fs 服务')
373
+ const target = await fs.resolve('.dsh/rules/' + input.file, { cwd: requireWorkspace(ctx, input.workspacePath).path })
374
+ await fs.writeText(target, content, void 0, void 0, fullPolicy(ctx))
375
+ } else {
376
+ await writeFile(absPath, content, 'utf8')
377
+ }
378
+ const info = await stat(absPath).catch(() => null)
379
+ return toRuleInfo(input.scope, input.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now())
380
+ }
381
+
382
+ /**
383
+ * 删除一条规则文件(project 作用域同样先过 workspace 注册校验)。
384
+ * @author ddj 2026年09月03号
385
+ * @param ref 作用域 + 文件名(+ 项目工作区)
386
+ */
387
+ export async function rulesRemove(ctx: Ctx, ref: RuleRefInput): Promise<void> {
388
+ const dir = dirOf(ctx, ref.scope, ref.workspacePath)
389
+ await rm(join(dir, ref.file), { force: true })
390
+ }
391
+
392
+ /**
393
+ * 切换规则启用开关:只改写 frontmatter enabled 行,其余内容零改动。
394
+ * @author ddj 2026年09月03号
395
+ * @param ref 作用域 + 文件名(+ 项目工作区)
396
+ * @param enabled 目标状态
397
+ * @returns 更新后的规则元信息
398
+ */
399
+ export async function rulesToggle(ctx: Ctx, ref: RuleRefInput, enabled: boolean): Promise<RuleInfo> {
400
+ const dir = dirOf(ctx, ref.scope, ref.workspacePath)
401
+ const absPath = join(dir, ref.file)
402
+ const text = await readRuleText(ctx, ref, absPath)
403
+ if (text === null) throw new Error('规则文件不存在:' + ref.file)
404
+ const next = toggleEnabledLine(text, enabled)
405
+ if (next === null) throw new Error('规则缺少 frontmatter(首行需为 ---),无法记录开关状态')
406
+ if (next !== text) await rulesSaveContent(ctx, ref, absPath, next)
407
+ return await ruleInfoOf(ref, absPath, next)
408
+ }
409
+
410
+ /** 按作用域读规则全文:project 与写盘同通道走 ctx fs,user 走 node fs;缺失返回 null。 */
411
+ async function readRuleText(ctx: Ctx, ref: RuleRefInput, absPath: string): Promise<string | null> {
412
+ if (ref.scope !== 'project') return readFile(absPath, 'utf8').catch(() => null)
413
+ const fs = ctx.get('fs')
414
+ if (!fs) throw new Error('缺少 fs 服务')
415
+ const target = await fs.resolve('.dsh/rules/' + ref.file, { cwd: requireWorkspace(ctx, ref.workspacePath).path })
416
+ try {
417
+ return await fs.readText(target)
418
+ } catch {
419
+ return null
420
+ }
421
+ }
422
+
423
+ /** rulesToggle 的写盘通道:与 rulesSave 相同(project 走 ctx fs + fullPolicy,user 走 node fs)。 */
424
+ async function rulesSaveContent(ctx: Ctx, ref: RuleRefInput, absPath: string, content: string): Promise<void> {
425
+ if (ref.scope !== 'project') {
426
+ await writeFile(absPath, content, 'utf8')
427
+ return
428
+ }
429
+ const fs = ctx.get('fs')
430
+ if (!fs) throw new Error('缺少 fs 服务')
431
+ const target = await fs.resolve('.dsh/rules/' + ref.file, { cwd: requireWorkspace(ctx, ref.workspacePath).path })
432
+ await fs.writeText(target, content, void 0, void 0, fullPolicy(ctx))
433
+ }
434
+
435
+ /** 重建单条规则元信息(toggle 后回传 UI;stat 失败时用内容长度 + 当前时间兜底)。 */
436
+ async function ruleInfoOf(ref: RuleRefInput, absPath: string, content: string): Promise<RuleInfo> {
437
+ const info = await stat(absPath).catch(() => null)
438
+ return toRuleInfo(ref.scope, ref.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now())
439
+ }
440
+ // --endregion
441
+
442
+ // --region systemPrompt 装配(注入生效通道)
443
+ /** 注入读取的 mtime 缓存项:mtimeMs+size 命中即复用解析结果,避免每次装配读盘解析。 */
444
+ interface InjCacheEntry {
445
+ mtimeMs: number
446
+ size: number
447
+ parsed: ParsedRule
448
+ }
449
+ const injCache = new Map<string, InjCacheEntry>()
450
+
451
+ /**
452
+ * 同步读取一个规则目录(装配 provider 内专用):仅 *.mdc,mtime 缓存,异常静默为空。
453
+ * @author ddj 2026年09月03号
454
+ * @param dir 规则目录
455
+ * @param scope 作用域
456
+ * @param relHint 相对提示
457
+ * @returns 已加载规则列表
458
+ */
459
+ function readRulesSync(dir: string, scope: RuleScope): LoadedRule[] {
460
+ if (!existsSync(dir)) return []
461
+ let names: string[]
462
+ try {
463
+ names = readdirSync(dir)
464
+ } catch {
465
+ return []
466
+ }
467
+ const out: LoadedRule[] = []
468
+ for (const file of names.filter((name) => name.endsWith('.mdc')).sort().slice(0, RULE_DIR_CAP)) {
469
+ const absPath = join(dir, file)
470
+ let info: { mtimeMs: number; size: number; isFile(): boolean }
471
+ try {
472
+ info = statSync(absPath)
473
+ } catch {
474
+ continue
475
+ }
476
+ if (!info.isFile()) continue
477
+ const cached = injCache.get(absPath)
478
+ let parsed: ParsedRule
479
+ if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
480
+ parsed = cached.parsed
481
+ } else {
482
+ try {
483
+ parsed = parseRuleMdc(readFileSync(absPath, 'utf8'))
484
+ } catch {
485
+ continue
486
+ }
487
+ injCache.set(absPath, { mtimeMs: info.mtimeMs, size: info.size, parsed })
488
+ }
489
+ out.push({
490
+ info: toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs),
491
+ body: parsed.body,
492
+ })
493
+ }
494
+ return out
495
+ }
496
+
497
+ /** 从装配上下文解析会话工作区 cwd(agent.id → sessions → header.cwd;缺链返回 null)。 */
498
+ function cwdFromAssemble(ctx: Ctx, asm: { agent?: { id?: unknown } } | undefined): string | null {
499
+ const id = asm?.agent?.id
500
+ if (typeof id !== 'string' || !id) return null
501
+ const session = ctx.get('sessions')?.get?.(id)
502
+ const cwd = session?.header?.cwd
503
+ return typeof cwd === 'string' && cwd ? cwd : null
504
+ }
505
+
506
+ /**
507
+ * 安装规则注入 section(可选探测 systemPrompt 服务;失败/缺失返回 false,不致命)。
508
+ * @author ddj 2026年09月03号
509
+ * @param ctx DSH host 上下文
510
+ * @returns 是否成功注册
511
+ */
512
+ export function installRulesSection(ctx: Ctx): boolean {
513
+ const sp = ctx.get('systemPrompt')
514
+ if (!sp || typeof sp.section !== 'function') return false
515
+ const provider = (asm: { agent?: { id?: unknown } }): string => {
516
+ try {
517
+ const user = readRulesSync(userRulesDir(), 'user')
518
+ const cwd = cwdFromAssemble(ctx, asm)
519
+ const project = cwd ? readRulesSync(projectRulesDir(cwd), 'project') : []
520
+ return renderRulesSection(user, project, cwd ?? undefined)
521
+ } catch {
522
+ return ''
523
+ }
524
+ }
525
+ ctx.effect(() => sp.section({ name: SECTION_NAME, order: SECTION_ORDER, text: provider }), 'vscode-mode: rules section')
526
+ return true
527
+ }
528
+ // --endregion
package/src/shared/rpc.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  } from './types.js'
15
15
  import type { MpcConfig, MpcProject, MpcProjectSaveInput, MpcServer } from './mcp.js'
16
16
  import type { CompatReport, DevFormInfo } from './compat.js'
17
+ import type { RuleInfo, RuleProject, RuleRefInput, RuleSaveInput } from './rules.js'
17
18
  import type { LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSymbol } from './lsp.js'
18
19
 
19
20
  /** webServer 精确路由。 */
@@ -165,6 +166,11 @@ export interface RpcRequestMap {
165
166
  'edrv.perf.configGet': {}
166
167
  'edrv.perf.configApply': {}
167
168
  'edrv.perf.configUndo': {}
169
+ 'rules.list': {}
170
+ 'rules.read': RuleRefInput
171
+ 'rules.save': RuleSaveInput
172
+ 'rules.remove': RuleRefInput
173
+ 'rules.toggle': RuleRefInput & { enabled: boolean }
168
174
  }
169
175
 
170
176
  export type RpcMethod = keyof RpcRequestMap
@@ -231,6 +237,11 @@ export interface RpcOkMap {
231
237
  'edrv.perf.configGet': { profileDir?: string; patchPath?: string; applied: boolean; block: string; backup?: string }
232
238
  'edrv.perf.configApply': { applied: boolean; backup: string; restart: boolean }
233
239
  'edrv.perf.configUndo': { restored: boolean; error?: string; backup?: string }
240
+ 'rules.list': { user: RuleInfo[]; projects: RuleProject[] }
241
+ 'rules.read': { content: string }
242
+ 'rules.save': { rule: RuleInfo }
243
+ 'rules.remove': object
244
+ 'rules.toggle': { rule: RuleInfo }
234
245
  }
235
246
 
236
247
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
@@ -0,0 +1,60 @@
1
+ /**
2
+ * dsh-vscode-mode 规则管理共享数据契约(.mdc 规则文件,参考 Codebuddy/Cursor 规则形态)。
3
+ * 纯类型模块:禁 node/react 导入(与 shared/mcp.ts 同约束)。
4
+ * 作者 ddj 2026年09月03号
5
+ */
6
+
7
+ /** 规则作用域:用户规则(~/.dsh/rules)或项目规则(<工作区>/.dsh/rules)。 */
8
+ export type RuleScope = 'user' | 'project'
9
+
10
+ /** 规则生效类型(由 frontmatter 推导):总是 / 自动(globs 命中时)/ 手动(仅索引)。 */
11
+ export type RuleType = 'always' | 'auto' | 'manual'
12
+
13
+ /** 一条 .mdc 规则的元信息(列表行展示 + 注入语义所需的最小集)。 */
14
+ export interface RuleInfo {
15
+ scope: RuleScope
16
+ /** 文件名(含 .mdc 后缀,不含路径)。 */
17
+ file: string
18
+ /** 绝对路径(host 解析,供展示与按需读取)。 */
19
+ absPath: string
20
+ /** 相对提示(用户层 rules/ 或项目层 .dsh/rules/)。 */
21
+ relHint: string
22
+ /** frontmatter description(缺失为空串)。 */
23
+ description: string
24
+ type: RuleType
25
+ /** frontmatter globs 归一化列表(类型=auto 时非空)。 */
26
+ globs: string[]
27
+ /** frontmatter enabled(缺省 true;false 时列表仍显示但不注入)。 */
28
+ enabled: boolean
29
+ /** 文件字节数(列表排序/超大提示用)。 */
30
+ size: number
31
+ /** 修改时间毫秒。 */
32
+ mtime: number
33
+ /** frontmatter 解析失败文案(不注入,仅 UI 提示)。 */
34
+ error?: string
35
+ }
36
+
37
+ /** 一个工作区的项目规则聚合(rules.list 的 projects 项)。 */
38
+ export interface RuleProject {
39
+ workspacePath: string
40
+ title: string
41
+ rules: RuleInfo[]
42
+ /** 工作区目录不存在或无规则目录时为 true(UI 显示空态而非报错)。 */
43
+ missingDir?: boolean
44
+ }
45
+
46
+ /** 规则保存入参:content 为完整 .mdc 文本(frontmatter + 正文,host 原样写盘)。 */
47
+ export interface RuleSaveInput {
48
+ scope: RuleScope
49
+ /** project 必填:目标工作区绝对路径(须为 DSH 已注册 workspace)。 */
50
+ workspacePath?: string
51
+ file: string
52
+ content: string
53
+ }
54
+
55
+ /** 规则读取/删除/开关入参公共字段。 */
56
+ export interface RuleRefInput {
57
+ scope: RuleScope
58
+ workspacePath?: string
59
+ file: string
60
+ }