@godv61/dsh-task-engine 0.19.1 → 0.19.2

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/.p0-test.mjs ADDED
@@ -0,0 +1,282 @@
1
+ // P0 acceptance test for dsh-task-engine 0.18.0: runs the pure engine +
2
+ // workflows functions and the registered `dev_task` tool against an in-memory
3
+ // fs, checking every P0 gate: capability binding, frozen snapshot, fail-closed
4
+ // unknown flow, core-rule shadow protection, and init three-phase flow.
5
+ import { readFileSync } from 'node:fs'
6
+ import { registerDevTask } from './lib/dev-task.js'
7
+ import { assertAdvance, checkFileScope, newTask, taskIdFromMessage, validateWorkflow } from './lib/engine.js'
8
+ import {
9
+ FLOW_PRESETS,
10
+ HIGH_RISK_REQUIRED_CAPABILITIES,
11
+ flowSatisfies,
12
+ resolveFlow,
13
+ } from './lib/workflows.js'
14
+
15
+ let passed = 0
16
+ function assert(cond, msg) {
17
+ if (!cond) {
18
+ console.error('FAIL:', msg)
19
+ throw new Error(msg)
20
+ }
21
+ passed++
22
+ }
23
+ async function assertThrows(fn, fragment, msg) {
24
+ try {
25
+ await fn()
26
+ } catch (err) {
27
+ const text = err instanceof Error ? err.message : String(err)
28
+ assert(text.includes(fragment), `${msg} (got: ${text})`)
29
+ return
30
+ }
31
+ throw new Error(`${msg}: expected throw containing "${fragment}"`)
32
+ }
33
+
34
+ function makeFs(initial = {}) {
35
+ const files = new Map(Object.entries(initial))
36
+ return {
37
+ async resolve(relPath, opts) {
38
+ const base = opts && opts.cwd ? String(opts.cwd).replace(/\\/g, '/') : ''
39
+ const key = base ? `${base}/${relPath}` : relPath
40
+ return { targetKey: key, displayPath: key }
41
+ },
42
+ async readText(target) {
43
+ return files.get(target.targetKey)
44
+ },
45
+ async writeText(target, content) {
46
+ files.set(target.targetKey, content)
47
+ },
48
+ _files: files,
49
+ }
50
+ }
51
+ function makeCtx(fs, approval) {
52
+ const tools = []
53
+ return {
54
+ fs,
55
+ tools: { register(tool) { tools.push(tool); return () => {} } },
56
+ get(service) {
57
+ if (service === 'approval') return approval
58
+ return undefined
59
+ },
60
+ _tools: tools,
61
+ }
62
+ }
63
+ async function registered(fs, approval) {
64
+ const ctx = makeCtx(fs, approval)
65
+ registerDevTask(ctx)
66
+ return ctx._tools[0].execute
67
+ }
68
+ const EXEC = { agent: undefined, callId: undefined, signal: undefined }
69
+ // A tool call whose session workspace is `cwd` (the harness process directory is
70
+ // deliberately different, exactly like a web session running in e2e-project).
71
+ function sessionExec(cwd) {
72
+ return { agent: { session: { header: { cwd } } }, callId: undefined, signal: undefined }
73
+ }
74
+
75
+ // ── 1. capability derivation and high-risk binding ────────────────────────
76
+ assert(flowSatisfies('standard', HIGH_RISK_REQUIRED_CAPABILITIES), 'standard satisfies high-risk capabilities')
77
+ assert(!flowSatisfies('agile', HIGH_RISK_REQUIRED_CAPABILITIES), 'agile lacks a high-risk capability')
78
+ assert(!flowSatisfies('minimal', HIGH_RISK_REQUIRED_CAPABILITIES), 'minimal lacks a high-risk capability')
79
+ assert(FLOW_PRESETS.standard.version === 1, 'preset carries a version')
80
+
81
+ // ── 2. unknown flow fails closed ───────────────────────────────────────────
82
+ const unknown = resolveFlow('nope')
83
+ assert(unknown.ok === false && unknown.code === 'UNKNOWN_FLOW', 'unknown flow returns UNKNOWN_FLOW')
84
+ assert(unknown.knownFlows.includes('standard'), 'known flow list is surfaced')
85
+
86
+ // ── 3. core bindings cannot be cancelled by an override ────────────────────
87
+ const merged = resolveFlow('standard', { stage_bindings: { '开发': { rules: [] } } })
88
+ assert(merged.ok && merged.config.stage_bindings['开发'].rules.includes('coding-conventions'),
89
+ 'emptying a stage override keeps the core rule')
90
+
91
+ // ── 4. newTask freezes the snapshot ────────────────────────────────────────
92
+ const snapshot = { flow: 'standard', version: 1, config: FLOW_PRESETS.standard.config }
93
+ const state = newTask({ id: 'T1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
94
+ assert(state.flow?.flow === 'standard' && state.flow.version === 1, 'newTask stores the frozen snapshot')
95
+ assert(state.stage === '需求评审', 'newTask starts at the frozen start stage')
96
+
97
+ // ── 5. high_risk create on minimal is rejected ─────────────────────────────
98
+ await assertThrows(
99
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) }))
100
+ .then(exe => exe({ operation: 'create', task_id: 'T2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)),
101
+ 'high_risk',
102
+ 'high_risk on minimal is rejected',
103
+ )
104
+
105
+ // ── 6. a task keeps its frozen snapshot after eng.json changes ─────────────
106
+ {
107
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
108
+ const exe = await registered(fs)
109
+ const created = await exe({ operation: 'create', task_id: 'T3', title: 'x', branch: 'main' }, EXEC)
110
+ assert(created.includes('需求评审'), 'standard create starts at 需求评审')
111
+ fs._files.set('.dsh/eng.json', JSON.stringify({ flow: 'minimal' }))
112
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'T3' }, EXEC))
113
+ assert(status.flow?.flow === 'standard' && status.flow.version === 1, 'status re-reads the frozen snapshot')
114
+ assert(Array.isArray(status.legal_next) && status.legal_next.includes('设计'),
115
+ 'gates come from the frozen standard flow, not the live minimal flow')
116
+ }
117
+
118
+ // ── 7. unknown flow blocks state-changing operations ───────────────────────
119
+ await assertThrows(
120
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'nope' }) }))
121
+ .then(exe => exe({ operation: 'create', task_id: 'X', title: 'y', branch: 'z' }, EXEC)),
122
+ 'unknown flow',
123
+ 'create on an unknown flow fails closed',
124
+ )
125
+
126
+ // ── 8. a file missing its flow field fails closed ──────────────────────────
127
+ await assertThrows(
128
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({}) }))
129
+ .then(exe => exe({ operation: 'create', task_id: 'X', title: 'y', branch: 'z' }, EXEC)),
130
+ 'missing a "flow" field',
131
+ 'eng.json without a flow field is a config error',
132
+ )
133
+
134
+ // ── 9. init: inspect → propose → apply, no direct overwrite ────────────────
135
+ {
136
+ const fs = makeFs()
137
+ const exe = await registered(fs)
138
+ const insp = await exe({ operation: 'init', phase: 'inspect' }, EXEC)
139
+ assert(insp.includes('no AGENTS.md'), 'inspect on empty project prompts a scan')
140
+ const prop = await exe({ operation: 'init', phase: 'propose', content: '# 项目\n示例' }, EXEC)
141
+ assert(prop.includes('proposed create'), 'propose previews without writing')
142
+ assert(fs._files.get('AGENTS.md') === undefined, 'propose writes nothing')
143
+ const app = await exe({ operation: 'init', phase: 'apply', content: '# 项目\n示例' }, EXEC)
144
+ assert(app.includes('wrote ./AGENTS.md'), 'apply writes the file')
145
+ assert(fs._files.get('AGENTS.md') === '# 项目\n示例', 'apply persisted the draft')
146
+ }
147
+
148
+ // ── 10. init protects an existing governance file ─────────────────────────
149
+ {
150
+ const fs = makeFs({ 'AGENTS.md': '# 已有治理文件\n' })
151
+ const exe = await registered(fs)
152
+ await assertThrows(
153
+ () => exe({ operation: 'init', phase: 'apply', content: '# new' }, EXEC),
154
+ 'protected',
155
+ 'apply without overwrite on an existing file is rejected',
156
+ )
157
+ await assertThrows(
158
+ () => exe({ operation: 'init', phase: 'apply', content: '# new', overwrite: true }, EXEC),
159
+ 'approval',
160
+ 'overwrite requires an approval service',
161
+ )
162
+ }
163
+
164
+ // ── 11. a project cannot shadow a bundled core rule ────────────────────────
165
+ {
166
+ const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
167
+ assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
168
+ const fs = makeFs({
169
+ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }),
170
+ '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
171
+ })
172
+ const exe = await registered(fs)
173
+ await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
174
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
175
+ const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
176
+ assert(sec !== undefined, 'security-redlines is disclosed at the start stage')
177
+ assert(!sec.content.includes('前端校验即可'), 'a project shadow of a core rule is ignored')
178
+ assert(sec.content.includes('服务端'), 'the bundled core rule body wins over a shadow')
179
+ }
180
+
181
+ // ── 12. session cwd drives every relative path (init regression) ──────────
182
+ {
183
+ const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
184
+ const fs = makeFs({
185
+ [`${cwd}/AGENTS.md`]: '# e2e 项目治理文件\n两行\n',
186
+ 'D:/dsharness/deepseek-harness/AGENTS.md': '# DeepSeek Harness monorepo (packages/pnpm)\n',
187
+ })
188
+ const exe = await registered(fs)
189
+ const insp = await exe({ operation: 'init', phase: 'inspect' }, sessionExec(cwd))
190
+ assert(insp.includes('# e2e 项目治理文件'), 'inspect reads the session workspace AGENTS.md')
191
+ assert(!insp.includes('monorepo'), 'inspect does not read the harness repo AGENTS.md')
192
+ }
193
+
194
+ // ── 13. create writes the task record into the session workspace ──────────
195
+ {
196
+ const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
197
+ const fs = makeFs({ [`${cwd}/.dsh/eng.json`]: JSON.stringify({ flow: 'standard' }) })
198
+ const exe = await registered(fs)
199
+ await exe({ operation: 'create', task_id: 'T12', title: 'x', branch: 'main' }, sessionExec(cwd))
200
+ assert(fs._files.get(`${cwd}/.dsh/task-T12.json`) !== undefined, 'task record lands in the session workspace')
201
+ assert(fs._files.get('.dsh/task-T12.json') === undefined, 'task record does not land in the backend base')
202
+ }
203
+
204
+ // ── 14. K: artifact ids must be unique across ALL stages ────────────────────
205
+ {
206
+ const withDup = { ...FLOW_PRESETS.standard.config, artifacts: [
207
+ { stage: '需求评审', id: 'doc', name: '需求', fields: ['scope'] },
208
+ { stage: '设计', id: 'doc', name: '设计', fields: ['approach'] },
209
+ ] }
210
+ const problems = validateWorkflow(withDup)
211
+ assert(problems.some(p => p.includes('duplicate artifact id "doc"')),
212
+ 'cross-stage duplicate artifact id is rejected')
213
+ }
214
+
215
+ // ── 15. K: record rejects an artifact owned by another stage ─────────────────
216
+ {
217
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
218
+ const exe = await registered(fs)
219
+ await exe({ operation: 'create', task_id: 'K1', title: 'x', branch: 'main' }, EXEC)
220
+ await assertThrows(
221
+ () => exe({ operation: 'record', task_id: 'K1', artifact: 'design', fields: { approach: 'a', risks: 'r', impact: 'i' } }, EXEC),
222
+ 'belongs to stage',
223
+ 'recording an artifact at a non-owning stage is rejected',
224
+ )
225
+ }
226
+
227
+ // ── 16. L: high-risk verification requires non-blank evidence ───────────────
228
+ {
229
+ const cfg = FLOW_PRESETS.standard.config
230
+ const mk = () => {
231
+ const s = newTask({ id: 'L1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'high_risk', flow: snapshot })
232
+ s.stage = '交付'
233
+ return s
234
+ }
235
+ let s = mk(); s.verification = { passed: true, evidence: [''] }
236
+ assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with empty-string evidence is blocked at the verified gate')
237
+ s = mk(); s.verification = { passed: true, evidence: [' '] }
238
+ assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with whitespace-only evidence is blocked')
239
+ s = mk(); s.verification = { passed: true, evidence: ['单测通过'] }
240
+ assert(assertAdvance(s, '代码审核', cfg).ok, 'high_risk with non-blank evidence passes the verified gate')
241
+ }
242
+
243
+ // ── 17. J: task id extracted from the summary (the hook uses it, not a guess) ─
244
+ {
245
+ const cfg = FLOW_PRESETS.standard.config
246
+ assert(taskIdFromMessage('【GREET-001】【TASK】实现登录', cfg) === 'GREET-001', 'standard summary yields its task id')
247
+ assert(taskIdFromMessage('【GREET-001】【T1】实现登录', cfg) === 'GREET-001', 'item-label summary yields its task id')
248
+ assert(taskIdFromMessage('实现登录', cfg) === undefined, 'non-matching summary yields no task id')
249
+ assert(taskIdFromMessage('随意', FLOW_PRESETS.minimal.config) === undefined, 'pattern-less flow yields no task id')
250
+ }
251
+
252
+ // ── 18. N: engine bookkeeping files are exempt from file scope ───────────────
253
+ {
254
+ const s = newTask({ id: 'N1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
255
+ s.files = ['src/a.ts']
256
+ assert(checkFileScope(s, ['src/a.ts', '.dsh/task-N1.json', '.dsh/eng.json'], FLOW_PRESETS.standard.config).ok,
257
+ 'task record and eng.json do not violate file scope')
258
+ assert(!checkFileScope(s, ['src/b.ts'], FLOW_PRESETS.standard.config).ok,
259
+ 'an out-of-scope code file is still flagged')
260
+ }
261
+
262
+ // ── 19. H/I: hook reads raw-byte paths, NUL-separated, including deletions ───
263
+ {
264
+ const hook = readFileSync('./hooks/commit-msg', 'utf8')
265
+ assert(hook.includes('-c core.quotePath=false'), 'hook disables git octal path quoting')
266
+ assert(hook.includes('-z'), 'hook reads NUL-separated raw-byte paths')
267
+ assert(hook.includes('--diff-filter=ACMRD'), 'hook includes deletions in the scope check')
268
+ }
269
+
270
+ // ── 20. 4.1/4.3: hook uses the frozen snapshot; writeInit guards overwrite ──
271
+ {
272
+ const hook = readFileSync('./hooks/commit-msg', 'utf8')
273
+ assert(hook.includes('state.flow && state.flow.config'), 'hook checks the task frozen flow snapshot')
274
+ assert(hook.includes('function extractTaskId'), 'hook extracts the task id independent of the live config')
275
+ }
276
+ {
277
+ const controller = readFileSync('./lib/controller.js', 'utf8')
278
+ assert(controller.includes('AGENTS.md 已存在且受保护'), 'workbench writeInit refuses to overwrite without an explicit intent')
279
+ assert(controller.includes('overwrite'), 'writeInit request carries an overwrite field')
280
+ }
281
+
282
+ console.log(`\nP0 acceptance: ${passed} checks passed`)
package/README.md CHANGED
@@ -9,7 +9,8 @@
9
9
  - **产物门**:每个阶段要交的产物 + 必填字段(需求说明 / 设计文档 / 评审记录…已固化在预设里),字段没填全 → 不让流转。
10
10
  - **提交门禁**:到检查点才能提交,消息必须匹配预设的提交格式,`manual` 策略永不自动提交。
11
11
  - **文件范围**:任务声明 `files`(本任务该改的文件),提交碰了范围外的文件 → 工具和 git 钩子都拒绝。
12
- - **skill / rule 挂载**:流程预设里每个阶段默认挂了「这个阶段用什么 skill」+「守哪条 rule」;项目可覆盖挂载,模型走到该阶段时,`dev_task` 只**渐进披露**这阶段挂载的内容,不一次性全给。内置一套通用库,也能在网页里新建自己的 skill/rule。
12
+ - **skill / rule 挂载**:流程预设里每个阶段默认挂了「这个阶段用什么 skill」+「守哪条 rule」;项目可在节点上**追加**自己的 skill/rule(内置绑定保留、不可移除,同名会被拒、只读内置版),模型走到该阶段时,`dev_task` 只**渐进披露**这阶段挂载的内容,不一次性全给。内置一套通用库,也能在网页里新建自己的 skill/rule。
13
+ - **边界(诚实说明)**:「需求确认」「方案确认」两扇门由**人工批准**点亮;但「实施项完成」「验证通过」「评审通过」是模型通过工具上报的状态,代码只校验这些状态在流程里自洽、格式/范围/绑定对齐,**不验证上报内容本身是否属实**——要硬保证请叠加外部 CI 或人工评审,不要把它当事实仲裁。
13
14
 
14
15
  ## 目录
15
16
 
@@ -74,7 +75,7 @@ dsh plugin --profile <name> add .
74
75
  name: '@godv61/dsh-task-engine/agent'
75
76
  ```
76
77
 
77
- 也可重跑 `dsh-task-engine-enable` 强制重建默认的 `eng`。
78
+ `dsh-task-engine-enable` 同样**只在 `eng` 不存在时创建**(已存在则直接退出、绝不覆盖);要重建请先删掉 `eng` 再重跑。
78
79
 
79
80
  ## 项目配置(选流程 + 挂 skill/rule)
80
81
 
@@ -93,7 +94,7 @@ dsh plugin --profile <name> add .
93
94
  - `flow`:选一套内置流程预设 —— `standard`(需求评审→设计→开发→交付→代码审核,含产物门+确认门)/ `agile`(需求→开发→交付→审查,四阶段少产物)/ `minimal`(开发→交付,只留提交门禁)。
94
95
  - `stage_bindings`:每个节点挂哪些 skill / rule(可整体省略,省略即用该预设自带的默认绑定)。
95
96
 
96
- **阶段图、流转守卫、产物字段、提交规则、验证证据开关全部固化在预设里,不再手工配置**。团队要改「提交消息格式」「交付要自测」这些约定,就覆盖内置的 `code-commit`/`code-verify` skill 和 `commit-conventions` rule(正文即规范),而不是改一堆参数。
97
+ **阶段图、流转守卫、产物字段、提交规则、验证证据开关全部固化在预设里,不再手工配置**。团队要改「提交消息格式」「交付要自测」这些约定,就新建一个项目级的新 skill/rule(用**新名字**,再挂到对应节点)——内置 skill/rule 的正文不可被同名覆盖(同名新建会被拒、解析同名只读内置版),新增的会与内置的一起挂在该节点上。
97
98
 
98
99
  `dev_task`(operation=config)随时查看当前生效配置;配置写错(未知 flow / 绑定到不存在的阶段 / 空名)会报错,不悄悄退回默认。
99
100
 
@@ -123,7 +124,7 @@ dsh plugin --profile <name> add .
123
124
  ```
124
125
 
125
126
  - **渐进披露**:模型走到某阶段时,`dev_task`(`status` / `advance`)只返回**这个阶段**挂载的 skill 名 + rule 内容;skill 用 DSH 的 `skill` 工具按名加载,rule 直接把正文给出。阶段推进到哪,才给哪。
126
- - **三层来源**:技能和规则都来自「内置 + 项目 + 用户」三层——内置(本包 `skills/`、`rules/`)、项目(`.dsh/skills/`、`.dsh/rules/`)、用户(`$DSH_HOME/skills/`、`$DSH_HOME/rules/`),同名时项目优先于用户优先于内置。
127
+ - **三层来源**:技能和规则都来自「内置 + 项目 + 用户」三层——内置(本包 `skills/`、`rules/`)、项目(`.dsh/skills/`、`.dsh/rules/`)、用户(`$DSH_HOME/skills/`、`$DSH_HOME/rules/`)。**同名时内置优先**:内置 skill/rule 不可被项目/用户同名覆盖(新建同名会被拒,解析同名只读内置版)。
127
128
  - **网页新建 / 查看 / 编辑 / 删除**:工作台「技能 / 规则」标签页里可新建、查看、编辑、删除项目级/用户级 skill 和 rule(内置只能查看、不可删改)。查看时正文以 markdown **富文本**渲染在宽弹窗里;编辑时正文保持纯文本。项目级写进 `.dsh/skills|rules/`(团队共享),用户级写进 `$DSH_HOME/skills|rules/`(个人全局),写好后自动出现在挂载清单里。
128
129
  - **内置库**:6 个节点技能(`requirement-analysis` / `solution-design` / `code-implement` / `code-verify` / `code-review` / `code-commit`)+ 3 条规则(`coding-conventions` / `commit-conventions` / `security-redlines`),开箱即用;想加项目专属的,用 DSH 原生技能机制或网页新建即可。
129
130
 
@@ -172,4 +173,5 @@ dsh plugin --profile <name> add .
172
173
  - **工程可靠性加固(0.18.0)**:① 高风险任务与流程能力绑定——`high_risk` 任务只能在具备「验证门 + 文件范围 + 评审门」能力的流程创建,选 `agile`/`minimal` 直接拒绝;② 任务创建时固化流程快照(预设 id + version + 完整配置),后续 `status`/`advance`/`verify`/`review`/`commit` 一律用快照,中途改 `.dsh/eng.json` 不再漂移在途任务的门禁;③ 未知流程失败关闭——`.dsh/eng.json` 缺 `flow` 或 `flow` 不在预设里一律报错(`UNKNOWN_FLOW` / 缺字段),不再静默回退 `standard`;④ 核心 skill/rule 不可取消、不可被同名覆盖——项目阶段绑定只能追加不能移除预设自带绑定,同名规则/技能读内置版、新建同名被拒;⑤ `init` 升级 `inspect → propose → apply` 三阶段,覆盖已有 `AGENTS.md` 需人工批准。
173
174
  - **会话工作目录修复(0.18.1)**:`dev_task` 的所有文件操作此前用 `fs.resolve(相对路径)` 不带 cwd,落到了 fs 后端默认目录(DSH 进程目录)而非会话工作区——在 web 会话里会把台账、配置、`AGENTS.md`、git 钩子写到/读到错误位置。改为从 `exec.agent.session.header.cwd` 取会话工作区并传给每个解析,补 4 项回归测试。
174
175
  - **工作台项目初始化(0.19.0)**:工作台新增置顶的「项目初始化」标签页——加载展示项目根 `AGENTS.md`、一键让 AI 扫描项目生成草稿(预览后再确认写回)、支持手动编辑与覆盖;Host 控制器新增 `readInit`/`writeInit`/`generateInit` 三个 Remote,`generateInit` 通过 `ctx.llm` + 默认模型在 Host 端直接生成,复用 `dev_task init` 的 200 行硬约束。
175
- - **使用手册跟进(0.19.1)**:随包发布的 `docs/manual.html` 补上工作台「项目初始化」(默认置顶标签页、项目根自动发现、AI 生成 150 秒超时 + 覆盖需人工确认),第 7 节标签页从「三个」改为「五个」;删除顶层已废弃的 `USER_GUIDE.html`(v0.9.1、无引用、不随包发布),README 目录结构描述同步为五标签页。
176
+ - **使用手册跟进(0.19.1)**:随包发布的 `docs/manual.html` 补上工作台「项目初始化」(默认置顶标签页、项目根自动发现、AI 生成 150 秒超时 + 覆盖需人工确认),第 7 节标签页从「三个」改为「五个」;安装章节补全 dsh CLI(非源码)安装方式与 pnpm 前置、`dsh plugin add` 的挂载机制;删除顶层已废弃的 `USER_GUIDE.html`(v0.9.1、无引用、不随包发布),README 目录结构描述同步为五标签页。
177
+ - **审计修复(0.19.2)**:补齐七项——① 提交钩子 `stagedFiles` 用 `core.quotePath=false` + `-z` 按 NUL 拆分(中文文件名不再被八进制转义误拒)并补 `D`(删除范围外文件也被拦);② 提交消息第一段改为 task id、钩子按 id 精确定位任务(不再按分支/mtime 猜,同分支多任务不再锁错);③ artifact id 全流程唯一 + `record` 校验产物属于当前阶段(堵越阶段复用);④ 高风险验证证据 `trim()` 后须非空(`evidence:[""]` 不再通过);⑤ README/手册加「诚实边界」,明说验证/评审/实施项是模型自报、需人工或 CI 兜底;⑥ 文档修正优先级(内置 &gt; 用户 &gt; 项目、内置不可覆盖)与 enable 措辞,`.dsh/task-*.json`/`eng.json` 豁免文件范围门;⑦ 提交钩子改用任务快照的 frozen 配置校验(对抗任务执行中改流程导致的配置漂移);⑧ 工作台 `writeInit` 对齐 `init` 保护(已有 `AGENTS.md` 时需显式 overwrite + 前端确认才覆盖);⑨ `.p0-test.mjs` 纳入发布包,装包后 `npm test` 可用。新增回归测试。
package/docs/manual.html CHANGED
@@ -80,22 +80,32 @@
80
80
  <p><strong>它不是常驻程序。</strong>只有你在「工程化开发引擎」预设的会话里提出开发请求时,才触发 <code>dev_task</code> 和这套门禁;换到别的预设,流程完全不介入。</p>
81
81
  </div>
82
82
  <p>三样东西分工明确:<strong>流程预设</strong>决定「走哪条流水线、有哪些守卫」,<strong>Skill</strong> 决定「这一站做什么」,<strong>Rule</strong> 决定「这一站守什么」。任务状态单独落盘,负责「跨会话恢复到哪一步」。</p>
83
+ <div class="callout warn">
84
+ <p><strong>诚实边界:</strong>阶段流转、提交格式、文件范围、消息里的任务绑定是<b>代码硬校验</b>(不匹配直接拒绝);但「验证通过」「评审通过」「实施项完成」是模型通过工具上报的状态,代码只校验这些状态在流程里自洽,<b>不验证上报内容本身是否属实</b>——要硬保证请叠加外部 CI 或人工评审。只有「需求确认」「方案确认」两扇门由人工批准点亮。</p>
85
+ </div>
83
86
  </section>
84
87
 
85
88
  <section id="install">
86
89
  <h2>2. 安装与启用</h2>
87
- <h3>先装 DSH(二选一)</h3>
88
- <pre><code># 方式 A:npm 一条命令(需要 Node.js ≥ 22)
90
+ <h3>2.1 前置:Node.js ≥ 22 + pnpm</h3>
91
+ <pre><code>npm install -g pnpm # dsh 的 plugin 子命令底层转发给 pnpm,必须先装</code></pre>
92
+ <h3>2.2 先装 DSH(三选一)</h3>
93
+ <pre><code># 方式 A:npm 装 CLI(非源码,推荐给使用者)
94
+ npm install -g @deepseek-ai/dsh
95
+ dsh web
96
+
97
+ # 方式 B:npx 免安装直接跑
89
98
  npx @deepseek-ai/dsh web
90
99
 
91
- # 方式 B:从源码 clone
100
+ # 方式 C:从源码 clone(开发者)
92
101
  git clone https://github.com/deepseek-ai/deepseek-harness.git
93
102
  cd deepseek-harness
94
103
  pnpm install &amp;&amp; pnpm run build &amp;&amp; pnpm dsh web</code></pre>
95
- <h3>再装引擎插件</h3>
104
+ <h3>2.3 把引擎插件装进 profile</h3>
96
105
  <pre><code>dsh plugin --profile web add @godv61/dsh-task-engine</code></pre>
97
- <p>重启 Web 后自动完成两件事:侧边栏多出「工程流程」工作台;预设列表多出「工程化开发引擎」。</p>
98
- <h3>启用</h3>
106
+ <p><code>dsh plugin --profile &lt;name&gt; add &lt;package&gt;</code> 会在 <code>$DSH_HOME/profiles/&lt;name&gt;/</code> 里执行 <code>pnpm add</code>,并自动识别插件声明的 <code>dsh.bundle.patch</code>,把它挂进该 profile 的插件层栈——<b>不要</b>手写 <code>npm i</code> 装到别处,那样不会挂载。</p>
107
+ <p>装完<b>重启 <code>dsh web</code></b> 生效,自动完成两件事:侧边栏多出「工程流程」工作台;预设列表多出「工程化开发引擎」。</p>
108
+ <h3>2.4 启用</h3>
99
109
  <p>新建会话 → 预设选「工程化开发引擎」。这个会话就挂上 <code>dev_task</code> + 内置技能 + 工程人设,开始按流程走。</p>
100
110
  <div class="callout"><p>想「正式开发」就选它;想「让 AI 自由探索」就选 <code>standard</code>。切换预设本身就是开关,零配置。</p></div>
101
111
  </section>
@@ -120,7 +130,7 @@ pnpm install &amp;&amp; pnpm run build &amp;&amp; pnpm dsh web</code></pre>
120
130
  <tr><td><code>.dsh/skills/</code></td><td>项目级 skill(团队共享)</td><td>节点挂载命中时按需读取</td></tr>
121
131
  <tr><td><code>.dsh/rules/</code></td><td>项目级 rule(团队共享)</td><td>节点挂载命中时按需读取</td></tr>
122
132
  <tr><td><code>.dsh/task-*.json</code></td><td>当前任务的有效状态快照</td><td>确认、实施、验证、评审、切换阶段时更新</td></tr>
123
- <tr><td><code>$DSH_HOME/skills/ · rules/</code></td><td>用户级 skill / rule</td><td>同名时优先于项目级、内置</td></tr>
133
+ <tr><td><code>$DSH_HOME/skills/ · rules/</code></td><td>用户级 skill / rule</td><td>同名时让位于内置(内置 &gt; 用户 &gt; 项目)</td></tr>
124
134
  </tbody>
125
135
  </table>
126
136
  </div>
@@ -178,7 +188,7 @@ pnpm install &amp;&amp; pnpm run build &amp;&amp; pnpm dsh web</code></pre>
178
188
  </tbody>
179
189
  </table>
180
190
  </div>
181
- <p>最重要的轻量原则:团队想改「提交消息格式」「交付要自测」这类约定,直接覆盖对应 rule / skill 的正文,而不是改一堆参数。</p>
191
+ <p>最重要的轻量原则:团队想改「提交消息格式」「交付要自测」这类约定,新建一个项目级规则 / 技能(用<b>新名字</b>)再挂到对应节点,而不是改一堆参数;内置 skill / rule 的正文<b>不可被同名覆盖</b>。</p>
182
192
  </section>
183
193
 
184
194
  <section id="configure">
@@ -198,7 +208,7 @@ pnpm install &amp;&amp; pnpm run build &amp;&amp; pnpm dsh web</code></pre>
198
208
  }
199
209
  }</code></pre>
200
210
  <div class="callout warn"><p>页面实时校验:挂到不存在的阶段、空名等会红字提示并置灰保存;保存前主机再校验一遍。错误的配置存不进去。</p></div>
201
- <p>三层来源与优先级:同名 skill / rule,<strong>项目级 &gt; 用户级 &gt; 内置</strong>。想改内置某条,新建同名项目级版本即可顶替,内置留着兜底。</p>
211
+ <p>三层来源与优先级:同名 skill / rule,<strong>内置 &gt; 用户 &gt; 项目</strong>——内置不可被同名覆盖(新建同名会被拒,解析同名只读内置版)。要加团队约定,新建一个<b>不同名</b>的项目级 / 用户级资源再挂到节点上。</p>
202
212
  </section>
203
213
 
204
214
  <section id="init">
@@ -352,8 +362,8 @@ risk_level: high_risk // 涉及鉴权,验证要加证据
352
362
  <p>引擎只允许:读取仓库事实、修改<b>已确认任务范围内</b>的文件、按策略创建<b>范围受控的本地提交</b>。远程 push / 合并 / 发布、数据库写入,需人单独决定。</p>
353
363
  </div>
354
364
  <details open><summary>切换预设就是开关吗?</summary><div>是。选「工程化开发引擎」才挂 <code>dev_task</code> 和内置技能;选 <code>standard</code> 等其它预设则完全不介入。</div></details>
355
- <details><summary>想改内置 skill / rule 怎么办?</summary><div>不删内置(升级又回来)。新建一个<b>同名</b>的项目级 skill / rule,系统自动用你这份,内置留着兜底。</div></details>
356
- <details><summary>任务文档要不要提交?</summary><div>要。<code>.dsh/task-*.json</code> 是团队共享、跨会话恢复的任务事实,随分支提交;忽略它别人只能看到代码,看不到确认内容和进度。</div></details>
365
+ <details><summary>想改内置 skill / rule 怎么办?</summary><div>内置正文不可被同名覆盖(同名新建会被拒)。要加团队约定,新建一个<b>不同名</b>的项目级 skill / rule 再挂到对应节点,与内置并存。</div></details>
366
+ <details><summary>任务文档要不要提交?</summary><div>要。<code>.dsh/task-*.json</code> 是团队共享、跨会话恢复的任务事实,随分支提交——引擎已豁免 <code>.dsh/task-*.json</code> 与 <code>.dsh/eng.json</code>,不受文件范围门拦截;忽略它别人只能看到代码,看不到确认内容和进度。</div></details>
357
367
  <details><summary>一个会话能同时做两个需求吗?</summary><div>一个任务对应一份状态文档、一个分支。新需求保存当前进度后切新分支,切回原分支即可恢复。</div></details>
358
368
  <details><summary>提交被拒是怎么回事?</summary><div>通常是四类:没建 dev_task 任务、阶段没到提交检查点、消息格式不符、提交文件不在任务范围内。按拒绝原因修正即可。</div></details>
359
369
  <details><summary>「完成」等于上线了吗?</summary><div>不等于。<code>done</code> 只表示本地验证与必要评审通过;上线、合并、发布需人另行决定。</div></details>
package/hooks/commit-msg CHANGED
@@ -51,7 +51,7 @@ const FLOWS = {
51
51
  commit: {
52
52
  policy: 'task',
53
53
  message_pattern: '^【(\\S+)】【(?:TASK|T\\d+)】.+',
54
- message_hint: '【<module>】【TASK/T1】说明',
54
+ message_hint: '【<task_id>】【TASK/T1】说明',
55
55
  checkpoints: ['交付'],
56
56
  file_scope: true,
57
57
  },
@@ -71,7 +71,7 @@ const FLOWS = {
71
71
  commit: {
72
72
  policy: 'item',
73
73
  message_pattern: '^【(\\S+)】【T\\d+】.+',
74
- message_hint: '【<module>】【T1】说明',
74
+ message_hint: '【<task_id>】【T1】说明',
75
75
  checkpoints: ['交付'],
76
76
  file_scope: true,
77
77
  },
@@ -110,7 +110,7 @@ function guardSatisfied(guard, state, config) {
110
110
  case 'verified':
111
111
  if (!state.verification || state.verification.passed !== true) return false
112
112
  return config.high_risk_requires_verification && state.risk_level === 'high_risk'
113
- ? Array.isArray(state.verification.evidence) && state.verification.evidence.length > 0
113
+ ? Array.isArray(state.verification.evidence) && state.verification.evidence.some(evidence => evidence.trim() !== '')
114
114
  : true
115
115
  case 'review_passed': return state.review && state.review.outcome === 'pass'
116
116
  case 'artifacts_present': {
@@ -157,12 +157,16 @@ function validateCommitMessage(message, config) {
157
157
  }
158
158
  }
159
159
 
160
- // Mirrors engine.checkFileScope.
160
+ // Mirrors engine.checkFileScope + engine.isEngineMeta.
161
+ function isEngineMeta(file) {
162
+ return /^\.dsh\/(task-[^/]+\.json|eng\.json)$/.test(file)
163
+ }
164
+
161
165
  function checkFileScope(state, committing, config) {
162
166
  if (!config.commit.file_scope) return { ok: true, outside: [], noScope: false }
163
167
  const files = Array.isArray(state.files) ? state.files : []
164
168
  if (files.length === 0) return { ok: false, outside: [], noScope: true }
165
- const outside = committing.filter(file => !files.includes(file))
169
+ const outside = committing.filter(file => !files.includes(file) && !isEngineMeta(file))
166
170
  return outside.length === 0
167
171
  ? { ok: true, outside: [], noScope: false }
168
172
  : { ok: false, outside, noScope: false }
@@ -170,8 +174,12 @@ function checkFileScope(state, committing, config) {
170
174
 
171
175
  function stagedFiles() {
172
176
  try {
173
- return execSync('git diff --cached --name-only --diff-filter=ACMR', { encoding: 'utf8' })
174
- .split('\n')
177
+ // `-z` emits raw bytes (no core.quotePath octal escaping) NUL-separated, so
178
+ // non-ASCII filenames survive the scope comparison verbatim; `quotePath=false`
179
+ // is a belt-and-braces fallback. `D` is included so a deletion outside the
180
+ // declared scope is caught too, not just adds/renames/modifies.
181
+ return execSync('git -c core.quotePath=false diff --cached --name-only -z --diff-filter=ACMRD', { encoding: 'utf8' })
182
+ .split('\0')
175
183
  .map(line => line.trim())
176
184
  .filter(Boolean)
177
185
  } catch {
@@ -229,6 +237,8 @@ function fileMtime(file) {
229
237
  }
230
238
  }
231
239
 
240
+ // Fallback only for pattern-less flows (e.g. minimal), which carry no task id in
241
+ // the summary. Pattern flows locate the exact task by id via parseTaskId below.
232
242
  function pickTask(tasks, branch) {
233
243
  if (tasks.length === 0) return undefined
234
244
  const byBranch = tasks.find(({ task }) => task.branch === branch)
@@ -238,16 +248,61 @@ function pickTask(tasks, branch) {
238
248
  return sorted[0].task
239
249
  }
240
250
 
251
+ function readCommitMessage() {
252
+ return fs.readFileSync(messageFile, 'utf8')
253
+ .split('\n')
254
+ .filter(line => !line.startsWith('#'))
255
+ .join('\n')
256
+ .trim()
257
+ }
258
+
259
+ // Extract the task id from the summary's leading 【…】 field. Deliberately
260
+ // config-independent (unlike engine.taskIdFromMessage): the hook must locate the
261
+ // task BEFORE it knows that task's frozen config, so it cannot use the config's
262
+ // pattern to parse the id. Pattern-less flows (minimal) simply carry no 【…】 here.
263
+ function extractTaskId(message) {
264
+ const match = /^【([^【】\s]+)】/.exec(message)
265
+ return match ? match[1] : undefined
266
+ }
267
+
241
268
  // --- the gate ---
242
269
  if (!messageFile) {
243
270
  process.exit(0)
244
271
  }
245
272
 
246
- const state = pickTask(loadTasks(), currentBranch())
273
+ const message = readCommitMessage()
274
+ const tasks = loadTasks()
275
+
276
+ // Locate the task by id from the summary first; only then read its frozen config.
277
+ // No branch/mtime guessing for pattern flows, so sibling tasks on one branch
278
+ // cannot be cross-locked.
279
+ const taskId = extractTaskId(message)
280
+ let state
281
+ if (taskId !== undefined) {
282
+ const found = tasks.find(({ task }) => task.id === taskId)
283
+ if (!found) {
284
+ refuse(`提交消息里的任务 id "${taskId}" 找不到对应的任务记录(.dsh/task-<id>.json)`)
285
+ }
286
+ state = found.task
287
+ } else {
288
+ state = pickTask(tasks, currentBranch())
289
+ }
247
290
  if (!state) {
248
291
  refuse('没有找到 dev_task 任务记录(.dsh/task-*.json)——请先 dev_task operation=create 建立任务')
249
292
  }
250
293
 
294
+ // 4.1 fix: check this task against its frozen flow snapshot, not the live
295
+ // .dsh/eng.json (which may have drifted mid-task). Fall back to the live config
296
+ // only for legacy records that predate the snapshot field.
297
+ if (state.flow && state.flow.config) {
298
+ config = state.flow.config
299
+ }
300
+
301
+ const verdict = validateCommitMessage(message, config)
302
+ if (!verdict.ok) {
303
+ refuse(verdict.reason)
304
+ }
305
+
251
306
  const checkpoint = commitCheckpoint(state, config)
252
307
  if (!checkpoint.allowed) {
253
308
  refuse(checkpoint.reason + '(当前阶段: ' + state.stage + ')')
@@ -261,15 +316,4 @@ if (!scope.ok) {
261
316
  refuse('提交了任务范围之外的文件: ' + scope.outside.join(', '))
262
317
  }
263
318
 
264
- const message = fs.readFileSync(messageFile, 'utf8')
265
- .split('\n')
266
- .filter(line => !line.startsWith('#'))
267
- .join('\n')
268
- .trim()
269
-
270
- const verdict = validateCommitMessage(message, config)
271
- if (!verdict.ok) {
272
- refuse(verdict.reason)
273
- }
274
-
275
319
  process.exit(0)
package/lib/client.js CHANGED
@@ -4207,7 +4207,8 @@ var initViewSchema = external_exports.object({
4207
4207
  });
4208
4208
  var initWriteRequestSchema = external_exports.object({
4209
4209
  path: external_exports.string(),
4210
- content: external_exports.string()
4210
+ content: external_exports.string(),
4211
+ overwrite: external_exports.boolean().optional()
4211
4212
  });
4212
4213
  var initWriteResultSchema = external_exports.object({
4213
4214
  ok: external_exports.boolean(),
@@ -4581,8 +4582,8 @@ function validateWorkflow(config) {
4581
4582
  if (!config.stages.includes(artifact.stage)) {
4582
4583
  problems.push(`artifact ${index + 1}: stage "${artifact.stage}" is not a stage`);
4583
4584
  }
4584
- const key = `${artifact.stage}:${artifact.id}`;
4585
- if (artifactKeys.has(key)) problems.push(`duplicate artifact "${key}"`);
4585
+ const key = artifact.id;
4586
+ if (artifactKeys.has(key)) problems.push(`duplicate artifact id "${key}" (ids are unique across all stages)`);
4586
4587
  artifactKeys.add(key);
4587
4588
  if (artifact.fields.length === 0) {
4588
4589
  problems.push(`artifact ${index + 1} ("${artifact.id}"): fields must not be empty`);
@@ -4653,7 +4654,7 @@ var STANDARD = {
4653
4654
  commit: {
4654
4655
  policy: "task",
4655
4656
  message_pattern: "^\u3010(\\S+)\u3011\u3010(?:TASK|T\\d+)\u3011.+",
4656
- message_hint: "\u3010<module>\u3011\u3010TASK/T1\u3011\u8BF4\u660E \u2014\u2014 \u5199\u7ED3\u679C\uFF0C\u4E0D\u5199\u7A7A\u6CDB\u52A8\u4F5C",
4657
+ message_hint: "\u3010<task_id>\u3011\u3010TASK/T1\u3011\u8BF4\u660E \u2014\u2014 \u7B2C\u4E00\u6BB5\u586B\u672C\u4EFB\u52A1 id\uFF08\u5982 GREET-001\uFF09\uFF0C\u5199\u7ED3\u679C\u4E0D\u5199\u7A7A\u6CDB\u52A8\u4F5C",
4657
4658
  checkpoints: ["\u4EA4\u4ED8"],
4658
4659
  file_scope: true
4659
4660
  },
@@ -4680,7 +4681,7 @@ var AGILE = {
4680
4681
  commit: {
4681
4682
  policy: "item",
4682
4683
  message_pattern: "^\u3010(\\S+)\u3011\u3010T\\d+\u3011.+",
4683
- message_hint: "\u3010<module>\u3011\u3010T1\u3011\u8BF4\u660E",
4684
+ message_hint: "\u3010<task_id>\u3011\u3010T1\u3011\u8BF4\u660E \u2014\u2014 \u7B2C\u4E00\u6BB5\u586B\u672C\u4EFB\u52A1 id",
4684
4685
  checkpoints: ["\u4EA4\u4ED8"],
4685
4686
  file_scope: true
4686
4687
  },
@@ -5071,9 +5072,9 @@ function InitPanel({ workspace, remote }) {
5071
5072
  }
5072
5073
  setDraft(r.value);
5073
5074
  };
5074
- const write = async (content) => {
5075
+ const write = async (content, overwrite) => {
5075
5076
  setMsg("");
5076
- const r = await remote.writeInit({ path: workspace, content });
5077
+ const r = await remote.writeInit({ path: workspace, content, overwrite });
5077
5078
  if (!r.ok) {
5078
5079
  setMsg("\u4FDD\u5B58\u5931\u8D25\uFF1A" + describeError(r.error));
5079
5080
  return;
@@ -5141,7 +5142,8 @@ function InitPanel({ workspace, remote }) {
5141
5142
  size: "md",
5142
5143
  disabled: draft.lines > 200,
5143
5144
  onClick: () => {
5144
- void write(draft.content);
5145
+ if (exists && !window.confirm("\u786E\u5B9A\u8986\u76D6\u73B0\u6709 AGENTS.md\uFF1F")) return;
5146
+ void write(draft.content, exists);
5145
5147
  }
5146
5148
  }, exists ? "\u4FDD\u5B58\u5E76\u8986\u76D6" : "\u4FDD\u5B58"),
5147
5149
  draft.lines > 200 ? (0, import_react2.createElement)("span", { style: styles.sourceBadge }, `\u8349\u7A3F ${draft.lines} \u884C\u8D85\u9650\uFF0C\u8BF7\u70B9\u300C\u653E\u5F03\u300D\u540E\u91CD\u8BD5`) : null,
@@ -5156,7 +5158,8 @@ function InitPanel({ workspace, remote }) {
5156
5158
  size: "md",
5157
5159
  disabled: body2.trim() === "",
5158
5160
  onClick: () => {
5159
- void write(body2);
5161
+ if (exists && !window.confirm("\u786E\u5B9A\u8986\u76D6\u73B0\u6709 AGENTS.md\uFF1F")) return;
5162
+ void write(body2, exists);
5160
5163
  }
5161
5164
  }, exists ? "\u4FDD\u5B58\u5E76\u8986\u76D6" : "\u4FDD\u5B58"),
5162
5165
  (0, import_react2.createElement)(import_dsh_client_ui_primitives2.Button, { variant: "ghost", size: "md", onClick: () => {