@godv61/dsh-task-engine 0.23.0 → 0.23.1

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.
Files changed (52) hide show
  1. package/.p0-test.mjs +739 -734
  2. package/.workflow-test.mjs +234 -0
  3. package/README.md +78 -184
  4. package/cordis.patch.yml +10 -10
  5. package/defaults/eng.json +10 -10
  6. package/docs/CHANGELOG.md +185 -0
  7. package/docs/README.md +27 -0
  8. package/docs/assets/workflow-banner.svg +29 -0
  9. package/docs/configuration.md +49 -0
  10. package/docs/development.md +53 -0
  11. package/docs/faq.md +43 -0
  12. package/docs/getting-started.md +51 -0
  13. package/docs/manual.html +17 -11
  14. package/docs/release-0.23.0.md +3 -5
  15. package/docs/release-0.23.1.md +58 -0
  16. package/docs/resource-install.md +55 -11
  17. package/docs/roadmap.md +33 -0
  18. package/docs/testing/0.23.1//346/265/213/350/257/225/346/212/245/345/221/212.md +34 -0
  19. package/docs/testing/0.23.1//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/346/230/216/347/273/206.md +41 -0
  20. package/docs/workflow-regression.md +36 -0
  21. package/hooks/commit-msg +4 -4
  22. package/lib/client.js +15 -14
  23. package/lib/client.js.map +3 -3
  24. package/lib/controller.js +5 -15
  25. package/lib/controller.js.map +1 -1
  26. package/lib/dev-task.js +188 -36
  27. package/lib/dev-task.js.map +1 -1
  28. package/lib/engine.d.ts +17 -1
  29. package/lib/engine.js +5 -0
  30. package/lib/engine.js.map +1 -1
  31. package/lib/shipped-skills.js +2 -1
  32. package/lib/shipped-skills.js.map +1 -1
  33. package/lib/skill-audit.d.ts +16 -0
  34. package/lib/skill-audit.js +60 -0
  35. package/lib/skill-audit.js.map +1 -0
  36. package/lib/workflows.js +4 -4
  37. package/lib/workflows.js.map +1 -1
  38. package/package.json +4 -3
  39. package/preset/agent.cordis.yml +21 -21
  40. package/preset/enable.mjs +87 -87
  41. package/preset/persona.md +4 -4
  42. package/preset/preset.yml +1 -1
  43. package/rules/coding-conventions.md +6 -6
  44. package/rules/commit-conventions.md +5 -5
  45. package/rules/security-redlines.md +5 -5
  46. package/skills/code-commit/SKILL.md +13 -13
  47. package/skills/code-implement/SKILL.md +23 -22
  48. package/skills/code-review/SKILL.md +13 -13
  49. package/skills/code-verify/SKILL.md +16 -11
  50. package/skills/eng-delivery/SKILL.md +38 -34
  51. package/skills/requirement-analysis/SKILL.md +15 -15
  52. package/skills/solution-design/SKILL.md +18 -15
package/.p0-test.mjs CHANGED
@@ -1,734 +1,739 @@
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
- if (process.env.DSH_TEST_DETAILS === '1') console.log(`PASS ${passed}: ${msg}`)
23
- }
24
- async function assertThrows(fn, fragment, msg) {
25
- try {
26
- await fn()
27
- } catch (err) {
28
- const text = err instanceof Error ? err.message : String(err)
29
- assert(text.includes(fragment), `${msg} (got: ${text})`)
30
- return
31
- }
32
- throw new Error(`${msg}: expected throw containing "${fragment}"`)
33
- }
34
-
35
- function makeFs(initial = {}) {
36
- const files = new Map(Object.entries(initial))
37
- const versions = new Map()
38
- return {
39
- async resolve(relPath, opts) {
40
- const base = opts && opts.cwd ? String(opts.cwd).replace(/\\/g, '/') : ''
41
- const key = base ? `${base}/${relPath}` : relPath
42
- return { targetKey: key, displayPath: key }
43
- },
44
- async readText(target) {
45
- return files.get(target.targetKey)
46
- },
47
- async lstat(path, opts) {
48
- const base = opts && opts.cwd ? String(opts.cwd).replace(/\\/g, '/') : ''
49
- const key = base ? `${base}/${path}` : path
50
- if (!files.has(key)) return undefined
51
- return { version: `v${versions.get(key) ?? 0}` }
52
- },
53
- async writeText(target, content, expected, signal, sandboxPolicy) {
54
- const key = target.targetKey
55
- if (expected !== undefined && expected !== null) {
56
- if (expected.kind === 'createIfAbsent' && files.has(key)) {
57
- const err = new Error('already exists')
58
- err.code = 'FS_NOT_OBSERVED'
59
- throw err
60
- }
61
- if (expected.kind === 'replaceIfVersion') {
62
- const current = `v${versions.get(key) ?? 0}`
63
- if (!files.has(key) || current !== expected.version) {
64
- const err = new Error('stale version')
65
- err.code = 'FS_STALE_VERSION'
66
- throw err
67
- }
68
- }
69
- }
70
- files.set(key, content)
71
- versions.set(key, (versions.get(key) ?? 0) + 1)
72
- },
73
- _files: files,
74
- }
75
- }
76
- function makeCtx(fs, approval) {
77
- const tools = []
78
- return {
79
- fs,
80
- tools: { register(tool) { tools.push(tool); return () => {} } },
81
- get(service) {
82
- if (service === 'approval') return approval
83
- return undefined
84
- },
85
- _tools: tools,
86
- }
87
- }
88
- async function registered(fs, approval) {
89
- const ctx = makeCtx(fs, approval)
90
- registerDevTask(ctx)
91
- return ctx._tools[0].execute
92
- }
93
- const EXEC = { agent: undefined, callId: undefined, signal: undefined }
94
- // A tool call whose session workspace is `cwd` (the harness process directory is
95
- // deliberately different, exactly like a web session running in e2e-project).
96
- function sessionExec(cwd) {
97
- return { agent: { session: { header: { cwd } } }, callId: undefined, signal: undefined }
98
- }
99
-
100
- // ── 1. capability derivation and high-risk binding ────────────────────────
101
- assert(flowSatisfies('standard', HIGH_RISK_REQUIRED_CAPABILITIES), 'standard satisfies high-risk capabilities')
102
- assert(!flowSatisfies('agile', HIGH_RISK_REQUIRED_CAPABILITIES), 'agile lacks a high-risk capability')
103
- assert(!flowSatisfies('minimal', HIGH_RISK_REQUIRED_CAPABILITIES), 'minimal lacks a high-risk capability')
104
- assert(FLOW_PRESETS.standard.version === 1, 'preset carries a version')
105
-
106
- // ── 2. unknown flow fails closed ───────────────────────────────────────────
107
- const unknown = resolveFlow('nope')
108
- assert(unknown.ok === false && unknown.code === 'UNKNOWN_FLOW', 'unknown flow returns UNKNOWN_FLOW')
109
- assert(unknown.knownFlows.includes('standard'), 'known flow list is surfaced')
110
-
111
- // ── 3. core bindings cannot be cancelled by an override ────────────────────
112
- const merged = resolveFlow('standard', { stage_bindings: { '开发': { rules: [] } } })
113
- assert(merged.ok && merged.config.stage_bindings['开发'].rules.includes('coding-conventions'),
114
- 'emptying a stage override keeps the core rule')
115
-
116
- // ── 4. newTask freezes the snapshot ────────────────────────────────────────
117
- const snapshot = { flow: 'standard', version: 1, config: FLOW_PRESETS.standard.config }
118
- const state = newTask({ id: 'T1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
119
- assert(state.flow?.flow === 'standard' && state.flow.version === 1, 'newTask stores the frozen snapshot')
120
- assert(state.stage === '需求评审', 'newTask starts at the frozen start stage')
121
-
122
- // ── 5. high_risk create on minimal is rejected ─────────────────────────────
123
- await assertThrows(
124
- () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) }))
125
- .then(exe => exe({ operation: 'create', task_id: 'T2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)),
126
- 'high_risk',
127
- 'high_risk on minimal is rejected',
128
- )
129
-
130
- // ── 6. a task keeps its frozen snapshot after eng.json changes ─────────────
131
- {
132
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
133
- const exe = await registered(fs)
134
- const created = await exe({ operation: 'create', task_id: 'T3', title: 'x', branch: 'main' }, EXEC)
135
- assert(created.includes('需求评审'), 'standard create starts at 需求评审')
136
- fs._files.set('.dsh/eng.json', JSON.stringify({ flow: 'minimal' }))
137
- const status = JSON.parse(await exe({ operation: 'status', task_id: 'T3' }, EXEC))
138
- assert(status.flow?.flow === 'standard' && status.flow.version === 1, 'status re-reads the frozen snapshot')
139
- assert(Array.isArray(status.legal_next) && status.legal_next.includes('设计'),
140
- 'gates come from the frozen standard flow, not the live minimal flow')
141
- }
142
-
143
- // ── 7. unknown flow blocks state-changing operations ───────────────────────
144
- await assertThrows(
145
- () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'nope' }) }))
146
- .then(exe => exe({ operation: 'create', task_id: 'X', title: 'y', branch: 'z' }, EXEC)),
147
- 'unknown flow',
148
- 'create on an unknown flow fails closed',
149
- )
150
-
151
- // ── 8. a file missing its flow field fails closed ──────────────────────────
152
- await assertThrows(
153
- () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({}) }))
154
- .then(exe => exe({ operation: 'create', task_id: 'X', title: 'y', branch: 'z' }, EXEC)),
155
- 'missing a "flow" field',
156
- 'eng.json without a flow field is a config error',
157
- )
158
-
159
- // ── 9. init: inspect → propose → apply, no direct overwrite ────────────────
160
- {
161
- const fs = makeFs()
162
- const exe = await registered(fs)
163
- const insp = await exe({ operation: 'init', phase: 'inspect' }, EXEC)
164
- assert(insp.includes('no AGENTS.md'), 'inspect on empty project prompts a scan')
165
- const prop = await exe({ operation: 'init', phase: 'propose', content: '# 项目\n示例' }, EXEC)
166
- assert(prop.includes('proposed create'), 'propose previews without writing')
167
- assert(fs._files.get('AGENTS.md') === undefined, 'propose writes nothing')
168
- const hash9 = /content hash: ([0-9a-f]{64})/.exec(prop)[1]
169
- const app = await exe({ operation: 'init', phase: 'apply', content: '# 项目\n示例', expected_hash: hash9 }, EXEC)
170
- assert(app.includes('wrote ./AGENTS.md'), 'apply writes the file')
171
- assert(fs._files.get('AGENTS.md') === '# 项目\n示例', 'apply persisted the draft')
172
- }
173
-
174
- // ── 10. init protects an existing governance file ─────────────────────────
175
- {
176
- const fs = makeFs({ 'AGENTS.md': '# 已有治理文件\n' })
177
- const exe = await registered(fs)
178
- const prop10 = await exe({ operation: 'init', phase: 'propose', content: '# new' }, EXEC)
179
- const hash10 = /content hash: ([0-9a-f]{64})/.exec(prop10)[1]
180
- const existingHash10 = /existing hash: ([0-9a-f]{64})/.exec(prop10)[1]
181
- assert(existingHash10 !== undefined, 'propose returns the existing-file hash when the file exists')
182
- await assertThrows(
183
- () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10 }, EXEC),
184
- 'requires existing_hash',
185
- 'apply without existing_hash on an existing file is rejected',
186
- )
187
- await assertThrows(
188
- () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10, existing_hash: '0'.repeat(64) }, EXEC),
189
- 'existing_hash mismatch',
190
- 'apply with a stale existing_hash is rejected (TOCTOU)',
191
- )
192
- await assertThrows(
193
- () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10, existing_hash: existingHash10 }, EXEC),
194
- 'protected',
195
- 'apply without overwrite on an existing file is rejected',
196
- )
197
- await assertThrows(
198
- () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10, existing_hash: existingHash10, overwrite: true }, EXEC),
199
- 'approval',
200
- 'overwrite requires an approval service',
201
- )
202
- await assertThrows(
203
- () => exe({ operation: 'init', phase: 'apply', content: '# new' }, EXEC),
204
- 'requires expected_hash',
205
- 'apply without expected_hash is rejected',
206
- )
207
- }
208
-
209
- // ── 11. a project cannot shadow a bundled core rule ────────────────────────
210
- {
211
- const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
212
- assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
213
- const fs = makeFs({
214
- '.dsh/eng.json': JSON.stringify({ flow: 'standard' }),
215
- '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
216
- })
217
- const exe = await registered(fs)
218
- await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
219
- const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
220
- const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
221
- assert(sec !== undefined, 'security-redlines is disclosed at the start stage')
222
- assert(!sec.content.includes('前端校验即可'), 'a project shadow of a core rule is ignored')
223
- assert(sec.content.includes('服务端'), 'the bundled core rule body wins over a shadow')
224
- }
225
-
226
- // ── 12. session cwd drives every relative path (init regression) ──────────
227
- {
228
- const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
229
- const fs = makeFs({
230
- [`${cwd}/AGENTS.md`]: '# e2e 项目治理文件\n两行\n',
231
- 'D:/dsharness/deepseek-harness/AGENTS.md': '# DeepSeek Harness monorepo (packages/pnpm)\n',
232
- })
233
- const exe = await registered(fs)
234
- const insp = await exe({ operation: 'init', phase: 'inspect' }, sessionExec(cwd))
235
- assert(insp.includes('# e2e 项目治理文件'), 'inspect reads the session workspace AGENTS.md')
236
- assert(!insp.includes('monorepo'), 'inspect does not read the harness repo AGENTS.md')
237
- }
238
-
239
- // ── 13. create writes the task record into the session workspace ──────────
240
- {
241
- const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
242
- const fs = makeFs({ [`${cwd}/.dsh/eng.json`]: JSON.stringify({ flow: 'standard' }) })
243
- const exe = await registered(fs)
244
- await exe({ operation: 'create', task_id: 'T12', title: 'x', branch: 'main' }, sessionExec(cwd))
245
- assert(fs._files.get(`${cwd}/.dsh/task-T12.json`) !== undefined, 'task record lands in the session workspace')
246
- assert(fs._files.get('.dsh/task-T12.json') === undefined, 'task record does not land in the backend base')
247
- }
248
-
249
- // ── 14. K: artifact ids must be unique across ALL stages ────────────────────
250
- {
251
- const withDup = { ...FLOW_PRESETS.standard.config, artifacts: [
252
- { stage: '需求评审', id: 'doc', name: '需求', fields: ['scope'] },
253
- { stage: '设计', id: 'doc', name: '设计', fields: ['approach'] },
254
- ] }
255
- const problems = validateWorkflow(withDup)
256
- assert(problems.some(p => p.includes('duplicate artifact id "doc"')),
257
- 'cross-stage duplicate artifact id is rejected')
258
- }
259
-
260
- // ── 15. K: record rejects an artifact owned by another stage ─────────────────
261
- {
262
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
263
- const exe = await registered(fs)
264
- await exe({ operation: 'create', task_id: 'K1', title: 'x', branch: 'main' }, EXEC)
265
- await assertThrows(
266
- () => exe({ operation: 'record', task_id: 'K1', artifact: 'design', fields: { approach: 'a', risks: 'r', impact: 'i' } }, EXEC),
267
- 'belongs to stage',
268
- 'recording an artifact at a non-owning stage is rejected',
269
- )
270
- }
271
-
272
- // ── 16. L/4.2: high-risk verification requires a REAL command receipt ────────
273
- {
274
- const cfg = FLOW_PRESETS.standard.config
275
- const receipt = overrides => ({
276
- command: 'npm test', exit_code: 0, timed_out: false, aborted: false,
277
- started_at: '2026-01-01T00:00:00Z', finished_at: '2026-01-01T00:00:01Z',
278
- stdout: '', stderr: '', ...overrides,
279
- })
280
- const mk = () => {
281
- const s = newTask({ id: 'L1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'high_risk', flow: snapshot })
282
- s.stage = '交付'
283
- return s
284
- }
285
- let s = mk(); s.verification = { passed: true, evidence: ['单测通过'] }
286
- assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with text-only evidence is blocked (no command receipt)')
287
- s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({}) }
288
- assert(assertAdvance(s, '代码审核', cfg).ok, 'high_risk with an exit-0 receipt passes the verified gate')
289
- s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({ exit_code: 1 }) }
290
- assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with a non-zero exit receipt is blocked')
291
- s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({ timed_out: true }) }
292
- assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with a timed-out receipt is blocked')
293
- s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({ aborted: true }) }
294
- assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with an aborted receipt is blocked')
295
- }
296
-
297
- // ── 17. J: task id extracted from the summary (the hook uses it, not a guess) ─
298
- {
299
- const cfg = FLOW_PRESETS.standard.config
300
- assert(taskIdFromMessage('【GREET-001】【TASK】实现登录', cfg) === 'GREET-001', 'standard summary yields its task id')
301
- assert(taskIdFromMessage('【GREET-001】【T1】实现登录', cfg) === 'GREET-001', 'item-label summary yields its task id')
302
- assert(taskIdFromMessage('实现登录', cfg) === undefined, 'non-matching summary yields no task id')
303
- assert(taskIdFromMessage('随意', FLOW_PRESETS.minimal.config) === undefined, 'pattern-less flow yields no task id')
304
- }
305
-
306
- // ── 18. N: engine bookkeeping files are exempt from file scope ───────────────
307
- {
308
- const s = newTask({ id: 'N1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
309
- s.files = ['src/a.ts']
310
- assert(checkFileScope(s, ['src/a.ts', '.dsh/task-N1.json', '.dsh/eng.json'], FLOW_PRESETS.standard.config).ok,
311
- 'task record and eng.json do not violate file scope')
312
- assert(!checkFileScope(s, ['src/b.ts'], FLOW_PRESETS.standard.config).ok,
313
- 'an out-of-scope code file is still flagged')
314
- }
315
-
316
- // ── 19. H/I: hook reads raw-byte paths, NUL-separated, including deletions ───
317
- {
318
- const hook = readFileSync('./hooks/commit-msg', 'utf8')
319
- assert(hook.includes('-c core.quotePath=false'), 'hook disables git octal path quoting')
320
- assert(hook.includes('-z'), 'hook reads NUL-separated raw-byte paths')
321
- assert(hook.includes('--diff-filter=ACMRDT'), 'hook includes deletions and type changes in the scope check')
322
- }
323
-
324
- // ── 20. 4.1/4.3: hook uses the frozen snapshot; writeInit guards overwrite ──
325
- {
326
- const hook = readFileSync('./hooks/commit-msg', 'utf8')
327
- assert(hook.includes('config = state.flow.config'), 'hook checks the task frozen flow snapshot')
328
- assert(hook.includes('function extractTaskId'), 'hook extracts the task id independent of the live config')
329
- assert(!hook.includes('const FLOWS'), 'hook bundles the single source (workflows.ts), not a hand mirror')
330
- }
331
- {
332
- const controller = readFileSync('./lib/controller.js', 'utf8')
333
- assert(controller.includes('AGENTS.md 已存在且受保护'), 'workbench writeInit refuses to overwrite without an explicit intent')
334
- assert(controller.includes('overwrite'), 'writeInit request carries an overwrite field')
335
- }
336
-
337
- // ── 21. 0.21-A: project adapter — type detection, root discovery, default verify commands ──
338
- const project = await import('./lib/project.js')
339
-
340
- function memProbe(files) {
341
- return {
342
- async read(dir, relPath) {
343
- const key = `${dir.replace(/\\/g, '/')}/${relPath}`.replace(/\/+/g, '/')
344
- return files[key]
345
- },
346
- }
347
- }
348
-
349
- {
350
- const probe = memProbe({ '/repo/package.json': '{}' })
351
- assert(await project.detectType(probe, '/repo') === 'node', 'node marker resolves to node')
352
- assert(await project.detectRoot(probe, '/repo/src/nested') === '/repo', 'root discovery climbs to the package.json directory')
353
- }
354
- {
355
- const probe = memProbe({ '/repo/pom.xml': '<project/>' })
356
- assert(await project.detectType(probe, '/repo') === 'java', 'pom.xml resolves to java')
357
- }
358
- {
359
- const probe = memProbe({ '/repo/go.mod': 'module x' })
360
- assert(await project.detectType(probe, '/repo') === 'go', 'go.mod resolves to go')
361
- }
362
- {
363
- const probe = memProbe({ '/repo/Cargo.toml': '[package]' })
364
- assert(await project.detectType(probe, '/repo') === 'rust', 'Cargo.toml resolves to rust')
365
- }
366
- {
367
- const probe = memProbe({ '/repo/requirements.txt': 'flask' })
368
- assert(await project.detectType(probe, '/repo') === 'python', 'requirements.txt resolves to python')
369
- }
370
- {
371
- const probe = memProbe({})
372
- assert(await project.detectType(probe, '/empty') === 'unknown', 'empty tree reports unknown type')
373
- assert(await project.detectRoot(probe, '/empty/deep/dir') === '/empty/deep/dir', 'root discovery falls back to the start directory')
374
- }
375
- {
376
- const probe = memProbe({ '/repo/package.json': '{}', '/repo/pyproject.toml': '[tool]' })
377
- assert(await project.detectType(probe, '/repo') === 'unknown', 'mixed tree reports unknown, never a silent single-language guess')
378
- }
379
- {
380
- const probe = memProbe({ '/repo/.git/HEAD': 'ref: refs/heads/main' })
381
- assert(await project.detectRoot(probe, '/repo/sub/module') === '/repo', 'root discovery stops at the git repository')
382
- }
383
- {
384
- assert(project.defaultVerifyCommand('node') === 'npm test', 'node default verify command')
385
- assert(project.defaultVerifyCommand('java') === 'mvn -q test', 'java default verify command')
386
- assert(project.defaultVerifyCommand('go') === 'go test ./...', 'go default verify command')
387
- assert(project.defaultVerifyCommand('unknown') === undefined, 'unknown type has no default command')
388
- }
389
- {
390
- const probe = memProbe({ '/repo/AGENTS.md': '# a', '/repo/CLAUDE.md': '# c' })
391
- const present = await project.presentGovernanceFiles(probe, '/repo')
392
- assert(present.includes('AGENTS.md') && present.includes('CLAUDE.md'), 'governance files are recognized as present')
393
- assert(!present.includes('.cursorrules'), 'absent governance files are not listed')
394
- }
395
-
396
- // ── 22. 0.21-C/D/E: out-of-root write guard, hook risk policy, project binding dirs ──
397
- {
398
- const { assertInsideRoot } = await import('./lib/dev-task.js')
399
- assertInsideRoot('/repo', '/repo/sub', 'AGENTS.md')
400
- assertInsideRoot('/repo/sub', '/repo/sub', 'AGENTS.md')
401
- await assertThrows(
402
- () => assertInsideRoot('/repo', '/repo', '../outside.txt'),
403
- 'escapes the project root',
404
- 'init write escaping the root is rejected',
405
- )
406
- await assertThrows(
407
- () => assertInsideRoot('/repo', '/repo', 'C:/elsewhere/x.txt'),
408
- 'escapes the project root',
409
- 'absolute target outside the root is rejected',
410
- )
411
- }
412
- {
413
- const hook = readFileSync('./hooks/commit-msg', 'utf8')
414
- assert(hook.includes('sensitive_paths'), 'hook bundles the risk policy table (single source)')
415
- assert(hook.includes('risk_level === "high_risk"') || hook.includes('risk_level === \'high_risk\''), 'hook requires high_risk before touching sensitive paths')
416
- }
417
- {
418
- const probe = {
419
- async read(dir, relPath) {
420
- const key = `${dir}/${relPath}`.replace(/\/+/g, '/')
421
- return files[key]
422
- },
423
- async list(dir, relPath) {
424
- const prefix = `${dir}/${relPath}`.replace(/\/+/g, '/')
425
- return Object.keys(files).filter(k => k.startsWith(prefix + '/')).map(k => k.slice(prefix.length + 1).split('/')[0])
426
- },
427
- }
428
- const files = {
429
- '/repo/.dsh/rules/api-conventions.md': '# r',
430
- '/repo/.dsh/rules/README.md': '# meta',
431
- '/repo/.dsh/skills/java-dev/SKILL.md': '# s',
432
- '/repo/.dsh/skills/empty/': '# no skill file',
433
- }
434
- const rules = await project.listProjectRules(probe, '/repo')
435
- assert(rules.length === 2 && rules.includes('api-conventions'), 'project rules list from .dsh/rules/*.md')
436
- const skills = await project.listProjectSkills(probe, '/repo')
437
- assert(skills.length === 1 && skills.includes('java-dev'), 'project skills list only dirs carrying SKILL.md')
438
- }
439
-
440
- // ── 23. 0.22-A: snapshot hash integrity ────────────────────────────────────
441
- {
442
- const { hashConfig } = await import('./lib/snapshot.js')
443
- const config = FLOW_PRESETS.standard.config
444
- const h1 = hashConfig(config)
445
- const h2 = hashConfig(JSON.parse(JSON.stringify(config)))
446
- assert(h1 === h2 && /^[0-9a-f]{64}$/.test(h1), 'hashConfig is a deterministic sha256')
447
- const tampered = JSON.parse(JSON.stringify(config))
448
- tampered.commit.file_scope = false
449
- assert(hashConfig(tampered) !== h1, 'editing the frozen config changes the hash')
450
- }
451
- {
452
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
453
- const exe = await registered(fs)
454
- await exe({ operation: 'create', task_id: 'HASH-1', title: 'x', branch: 'main' }, EXEC)
455
- const record = JSON.parse(fs._files.get('.dsh/task-HASH-1.json'))
456
- record.flow.config.commit.file_scope = false
457
- fs._files.set('.dsh/task-HASH-1.json', JSON.stringify(record))
458
- await assertThrows(
459
- () => exe({ operation: 'status', task_id: 'HASH-1' }, EXEC),
460
- 'hash mismatch',
461
- 'status on a tampered snapshot is rejected',
462
- )
463
- }
464
- {
465
- const hook = readFileSync('./hooks/commit-msg', 'utf8')
466
- assert(hook.includes('hashConfig'), 'hook reuses the single hashConfig source')
467
- assert(hook.includes('sha256') || hook.includes('createHash'), 'hook bundles the sha256 snapshot check')
468
- }
469
-
470
- // ── 24. 0.22-B: installed-hook integrity check ─────────────────────────────
471
- {
472
- const fs = makeFs({})
473
- const exe = await registered(fs)
474
- assert((await exe({ operation: 'verify_hook' }, EXEC)).includes('run install_hook'), 'verify_hook reports an absent hook')
475
- await exe({ operation: 'install_hook' }, EXEC)
476
- assert((await exe({ operation: 'verify_hook' }, EXEC)).includes('integrity OK'), 'verify_hook passes on the bundled hook')
477
- fs._files.set('.git/hooks/commit-msg', fs._files.get('.git/hooks/commit-msg') + '\n// tampered')
478
- await assertThrows(
479
- () => exe({ operation: 'verify_hook' }, EXEC),
480
- 'integrity FAILED',
481
- 'verify_hook rejects a tampered hook',
482
- )
483
- }
484
-
485
- // ── 25. 0.22-B: risk downgrade approval + bindings fingerprint ─────────────
486
- {
487
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
488
- const exe = await registered(fs)
489
- await exe({ operation: 'create', task_id: 'RISK-1', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
490
- await assertThrows(
491
- () => exe({ operation: 'set_risk', task_id: 'RISK-1', risk_level: 'standard' }, EXEC),
492
- 'approval',
493
- 'downgrade without an approval service is rejected',
494
- )
495
- assert((await exe({ operation: 'set_risk', task_id: 'RISK-1', risk_level: 'high_risk' }, EXEC)).includes('already'),
496
- 'same-risk set_risk is a no-op')
497
- }
498
- {
499
- const approval = { async request() { return 'allowed-once' } }
500
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
501
- const exe = await registered(fs, approval)
502
- await exe({ operation: 'create', task_id: 'RISK-2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
503
- assert((await exe({ operation: 'set_risk', task_id: 'RISK-2', risk_level: 'standard' }, EXEC)).includes('risk_level set to standard'),
504
- 'approved downgrade succeeds')
505
- const status = JSON.parse(await exe({ operation: 'status', task_id: 'RISK-2' }, EXEC))
506
- assert(status.risk_level === 'standard' && status.risk_downgrades.length === 1,
507
- 'downgrade is recorded in the audit trail')
508
- }
509
- {
510
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
511
- const exe = await registered(fs)
512
- await exe({ operation: 'create', task_id: 'FP-1', title: 'x', branch: 'main' }, EXEC)
513
- const status = JSON.parse(await exe({ operation: 'status', task_id: 'FP-1' }, EXEC))
514
- assert(status.bindings_drift === undefined, 'fresh task shows no bindings drift')
515
- const record = JSON.parse(fs._files.get('.dsh/task-FP-1.json'))
516
- assert(typeof record.bindings_fingerprint === 'string' && record.bindings_fingerprint.length === 64,
517
- 'create records the bundled-rules fingerprint')
518
- }
519
-
520
- // ── 26. 0.22-C: multi-language verify command chain + parallel tasks ────────
521
- {
522
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard', verify_command: 'mvn test' }) })
523
- const exe = await registered(fs)
524
- await exe({ operation: 'create', task_id: 'V-1', title: 'x', branch: 'main' }, EXEC)
525
- await assertThrows(
526
- () => exe({ operation: 'verify', task_id: 'V-1' }, EXEC),
527
- 'shell',
528
- 'verify falls back to the eng.json command and fails loud without a shell service',
529
- )
530
- }
531
- {
532
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
533
- const exe = await registered(fs)
534
- await exe({ operation: 'create', task_id: 'V-2', title: 'x', branch: 'main' }, EXEC)
535
- await assertThrows(
536
- () => exe({ operation: 'verify', task_id: 'V-2' }, EXEC),
537
- 'shell',
538
- 'verify falls back to the language default (node → npm test) and fails loud without a shell service',
539
- )
540
- }
541
- {
542
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
543
- const exe = await registered(fs)
544
- await exe({ operation: 'create', task_id: 'V-3', title: 'x', branch: 'main' }, EXEC)
545
- assert((await exe({ operation: 'verify', task_id: 'V-3', passed: true }, EXEC)).includes('ok'),
546
- 'unknown-type verify keeps the legacy self-reported path')
547
- }
548
- {
549
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
550
- const exe = await registered(fs)
551
- await exe({ operation: 'create', task_id: 'A-1', title: 'x', branch: 'main' }, EXEC)
552
- await exe({ operation: 'create', task_id: 'B-2', title: 'y', branch: 'main' }, EXEC)
553
- await exe({ operation: 'scope', task_id: 'A-1', files: ['src/a.ts'] }, EXEC)
554
- const a = JSON.parse(await exe({ operation: 'status', task_id: 'A-1' }, EXEC))
555
- const b = JSON.parse(await exe({ operation: 'status', task_id: 'B-2' }, EXEC))
556
- assert(a.files.length === 1 && b.files.length === 0, 'two tasks in one repo advance independently')
557
- }
558
-
559
- // ── 27. 9.4: init apply validates the expected hash ─────────────────────────
560
- {
561
- const fs = makeFs({})
562
- const exe = await registered(fs)
563
- const propose = await exe({ operation: 'init', phase: 'propose', content: 'hello' }, EXEC)
564
- const match = /content hash: ([0-9a-f]{64})/.exec(propose)
565
- assert(match !== null, 'propose returns the content hash')
566
- await assertThrows(
567
- () => exe({ operation: 'init', phase: 'apply', content: 'hello', expected_hash: '0'.repeat(64) }, EXEC),
568
- 'expected_hash mismatch',
569
- 'apply with a mismatched hash is rejected',
570
- )
571
- assert((await exe({ operation: 'init', phase: 'apply', content: 'hello', expected_hash: match[1] }, EXEC)).includes('wrote'),
572
- 'apply with the matching hash writes the file')
573
- }
574
-
575
- // ── 28. risk policy excludes project binding dirs ───────────────────────────
576
- {
577
- const policy = project.DEFAULT_RISK_POLICY
578
- assert(!policy.sensitive_paths.includes('.dsh'),
579
- 'project binding dir (.dsh/rules, .dsh/skills) stays committable by normal tasks')
580
- assert(policy.sensitive_paths.includes('.env'), 'env files remain sensitive')
581
- }
582
-
583
- // ── 29. dev_task writes carry the per-call sandbox policy ───────────────────
584
- {
585
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
586
- let seenPolicy
587
- const origWrite = fs.writeText
588
- fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
589
- seenPolicy = sandboxPolicy
590
- return origWrite(target, content)
591
- }
592
- const exe = await registered(fs)
593
- await exe({ operation: 'create', task_id: 'POL-1', title: 'x', branch: 'main' }, EXEC)
594
- assert(seenPolicy !== undefined && seenPolicy.mode === 'workspace-write',
595
- 'dev_task writes carry the sandbox mode (default workspace-write)')
596
- assert(seenPolicy.workspaceRoot === process.cwd(),
597
- 'dev_task writes carry the session workspace root so in-workspace paths are never misjudged as out-of-root')
598
- await assertThrows(
599
- () => exe({ operation: 'create', task_id: 'POL-2', title: 'x', branch: 'main', sandbox_permissions: 'danger-full-access' }, EXEC),
600
- 'requires a justification',
601
- 'escalation without a justification is rejected',
602
- )
603
- }
604
-
605
- // ── 30. 0.22.x: sandbox escalation requires a human one-shot approval ───────
606
- {
607
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
608
- let seenPolicy
609
- const origWrite = fs.writeText
610
- fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
611
- seenPolicy = sandboxPolicy
612
- return origWrite(target, content)
613
- }
614
- const approval = { async request() { return 'allowed-once' } }
615
- const exe = await registered(fs, approval)
616
- await exe({
617
- operation: 'create', task_id: 'POL-3', title: 'x', branch: 'main',
618
- sandbox_permissions: 'danger-full-access', justification: 'the task record must land despite the sandbox denial',
619
- }, EXEC)
620
- assert(seenPolicy.mode === 'danger-full-access',
621
- 'approved escalation upgrades the per-call write mode')
622
- }
623
-
624
- {
625
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
626
- const exe = await registered(fs) // no approval service
627
- await assertThrows(
628
- () => exe({
629
- operation: 'create', task_id: 'POL-4', title: 'x', branch: 'main',
630
- sandbox_permissions: 'danger-full-access', justification: 'why',
631
- }, EXEC),
632
- 'approval service is unavailable',
633
- 'escalation without an approval service fails closed',
634
- )
635
- }
636
-
637
- // ── 31. 0.22.x: set_risk cannot raise risk past the flow's capabilities ─────
638
- {
639
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) })
640
- const exe = await registered(fs)
641
- await exe({ operation: 'create', task_id: 'SR-1', title: 'x', branch: 'main' }, EXEC)
642
- await assertThrows(
643
- () => exe({ operation: 'set_risk', task_id: 'SR-1', risk_level: 'high_risk' }, EXEC),
644
- 'lacks the required capabilities',
645
- 'raising risk on a minimal flow is rejected (same gate as create)',
646
- )
647
- }
648
- {
649
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
650
- const exe = await registered(fs)
651
- await exe({ operation: 'create', task_id: 'SR-2', title: 'x', branch: 'main' }, EXEC)
652
- assert((await exe({ operation: 'set_risk', task_id: 'SR-2', risk_level: 'high_risk' }, EXEC)).includes('high_risk'),
653
- 'raising risk on the standard flow succeeds')
654
- }
655
-
656
- // ── 32. 0.22.x: remote rejects non-absolute / system-root workspace paths ───
657
- {
658
- const controller = readFileSync('./lib/controller.js', 'utf8')
659
- assert(controller.includes('workspace path must be absolute'), 'remote rejects non-absolute workspace paths')
660
- assert(controller.includes('not an allowed workspace'), 'remote rejects system-wide roots')
661
- }
662
-
663
- // ── 33. 0.23: verify runs in the task's recorded project root ──────────────
664
- {
665
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
666
- const seenWorkdirs = []
667
- const shell = {
668
- resolve(request) { return { command: request.command, workdir: request.workdir ?? '', timeoutMs: request.timeoutMs } },
669
- async run(spec) { seenWorkdirs.push(spec.workdir); return { exitCode: 0, timedOut: false, aborted: false, stdout: { text: 'ok' }, stderr: { text: '' } } },
670
- }
671
- const ctx = makeCtx(fs, undefined)
672
- ctx.get = (service) => (service === 'shell' ? shell : service === 'approval' ? undefined : undefined)
673
- registerDevTask(ctx)
674
- const exe = ctx._tools[0].execute
675
- await exe({ operation: 'create', task_id: 'VR-1', title: 'x', branch: 'main' }, EXEC)
676
- await exe({ operation: 'verify', task_id: 'VR-1' }, EXEC)
677
- const record = JSON.parse(fs._files.get('.dsh/task-VR-1.json'))
678
- assert(seenWorkdirs.length === 1, 'bare verify ran the default command')
679
- assert(seenWorkdirs[0] === (record.root ?? ''), 'verify ran in the recorded project root, not the session cwd')
680
- assert(record.verification.receipt.root === record.root, 'receipt binds the recorded project root')
681
- }
682
-
683
- // ── 34. 0.23: revision increments on every write (CAS foundation) ───────────
684
- {
685
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
686
- const exe = await registered(fs)
687
- await exe({ operation: 'create', task_id: 'CAS-1', title: 'x', branch: 'main' }, EXEC)
688
- const r1 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
689
- await exe({ operation: 'scope', task_id: 'CAS-1', files: ['a'] }, EXEC)
690
- const r2 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
691
- assert(r1 === 1 && r2 === 2, 'revision increments on every write')
692
- const devtask = readFileSync('./lib/dev-task.js', 'utf8')
693
- assert(devtask.includes('changed concurrently'), 'concurrent-write CAS guard is present')
694
- }
695
-
696
- // ── 35. 0.23: Remote path guard + workspace registry ────────────────────────
697
- {
698
- const ctl = await import('./lib/controller.js')
699
- assert(ctl.checkedPath('D:/proj/one') === 'D:/proj/one', 'absolute workspace passes')
700
- assert(ctl.checkedPath('D:/proj/one/') === 'D:/proj/one', 'trailing slash is normalized')
701
- await assertThrows(() => ctl.checkedPath('relative/path'), 'must be absolute', 'relative path is rejected')
702
- await assertThrows(() => ctl.checkedPath('C:/Windows/system32'), 'not an allowed workspace', 'system root is rejected')
703
- await assertThrows(() => ctl.checkedPath('c:/windows'), 'not an allowed workspace', 'case variant of a system root is rejected')
704
- ctl.registerWorkspace('D:/proj/one')
705
- assert(ctl.checkedPath('D:/PROJ/one') === 'D:/PROJ/one', 'registered workspace passes under any case')
706
- await assertThrows(() => ctl.checkedPath('D:/other-project'), 'not registered', 'unregistered workspace is rejected once the registry is active')
707
- ctl.enableStrictWorkspaces()
708
- await assertThrows(() => ctl.checkedPath('D:/still-unregistered'), 'not registered', 'strict mode keeps rejecting unregistered workspaces')
709
- assert(ctl.checkedPath('D:/proj/one') === 'D:/proj/one', 'strict mode still admits registered workspaces')
710
- }
711
-
712
-
713
- // A second writer between content read and write must not be silently overwritten.
714
- {
715
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
716
- const exe = await registered(fs)
717
- await exe({ operation: 'create', task_id: 'CAS-RACE', title: 'race', branch: 'main' }, EXEC)
718
- const originalRead = fs.readText.bind(fs)
719
- let taskReads = 0
720
- fs.readText = async target => {
721
- const value = await originalRead(target)
722
- if (target.targetKey === '.dsh/task-CAS-RACE.json' && ++taskReads === 2) {
723
- const other = JSON.parse(value)
724
- other.title = 'concurrent writer'
725
- other.revision++
726
- await fs.writeText(target, JSON.stringify(other))
727
- }
728
- return value
729
- }
730
- await assertThrows(() => exe({ operation: 'scope', task_id: 'CAS-RACE', files: ['a'] }, EXEC), 'changed concurrently', 'CAS rejects writes when version changes after the content read')
731
- assert(JSON.parse(fs._files.get('.dsh/task-CAS-RACE.json')).title === 'concurrent writer', 'CAS preserves the concurrent writer record')
732
- }
733
-
734
- console.log(`\nP0 acceptance: ${passed} checks passed`)
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
+ if (process.env.DSH_TEST_DETAILS === '1') console.log(`PASS ${passed}: ${msg}`)
23
+ }
24
+ async function assertThrows(fn, fragment, msg) {
25
+ try {
26
+ await fn()
27
+ } catch (err) {
28
+ const text = err instanceof Error ? err.message : String(err)
29
+ assert(text.includes(fragment), `${msg} (got: ${text})`)
30
+ return
31
+ }
32
+ throw new Error(`${msg}: expected throw containing "${fragment}"`)
33
+ }
34
+
35
+ function makeFs(initial = {}) {
36
+ const files = new Map(Object.entries(initial))
37
+ const versions = new Map()
38
+ return {
39
+ async resolve(relPath, opts) {
40
+ const base = opts && opts.cwd ? String(opts.cwd).replace(/\\/g, '/') : ''
41
+ const key = base ? `${base}/${relPath}` : relPath
42
+ return { targetKey: key, displayPath: key }
43
+ },
44
+ async readText(target) {
45
+ return files.get(target.targetKey)
46
+ },
47
+ async lstat(path, opts) {
48
+ const base = opts && opts.cwd ? String(opts.cwd).replace(/\\/g, '/') : ''
49
+ const key = base ? `${base}/${path}` : path
50
+ if (!files.has(key)) return undefined
51
+ return { version: `v${versions.get(key) ?? 0}` }
52
+ },
53
+ async writeText(target, content, expected, signal, sandboxPolicy) {
54
+ const key = target.targetKey
55
+ if (expected !== undefined && expected !== null) {
56
+ if (expected.kind === 'createIfAbsent' && files.has(key)) {
57
+ const err = new Error('already exists')
58
+ err.code = 'FS_NOT_OBSERVED'
59
+ throw err
60
+ }
61
+ if (expected.kind === 'replaceIfVersion') {
62
+ const current = `v${versions.get(key) ?? 0}`
63
+ if (!files.has(key) || current !== expected.version) {
64
+ const err = new Error('stale version')
65
+ err.code = 'FS_STALE_VERSION'
66
+ throw err
67
+ }
68
+ }
69
+ }
70
+ files.set(key, content)
71
+ versions.set(key, (versions.get(key) ?? 0) + 1)
72
+ },
73
+ _files: files,
74
+ }
75
+ }
76
+ function makeCtx(fs, approval) {
77
+ const tools = []
78
+ return {
79
+ fs,
80
+ tools: { register(tool) { tools.push(tool); return () => {} } },
81
+ get(service) {
82
+ if (service === 'approval') return approval
83
+ return undefined
84
+ },
85
+ _tools: tools,
86
+ }
87
+ }
88
+ async function registered(fs, approval) {
89
+ const ctx = makeCtx(fs, approval)
90
+ registerDevTask(ctx)
91
+ return ctx._tools[0].execute
92
+ }
93
+ const EXEC = { agent: undefined, callId: undefined, signal: undefined }
94
+ // A tool call whose session workspace is `cwd` (the harness process directory is
95
+ // deliberately different, exactly like a web session running in e2e-project).
96
+ function sessionExec(cwd) {
97
+ return { agent: { session: { header: { cwd } } }, callId: undefined, signal: undefined }
98
+ }
99
+
100
+ // ── 1. capability derivation and high-risk binding ────────────────────────
101
+ assert(flowSatisfies('standard', HIGH_RISK_REQUIRED_CAPABILITIES), 'standard satisfies high-risk capabilities')
102
+ assert(!flowSatisfies('agile', HIGH_RISK_REQUIRED_CAPABILITIES), 'agile lacks a high-risk capability')
103
+ assert(!flowSatisfies('minimal', HIGH_RISK_REQUIRED_CAPABILITIES), 'minimal lacks a high-risk capability')
104
+ assert(FLOW_PRESETS.standard.version === 2, 'standard preset carries the review-before-commit version')
105
+
106
+ // ── 2. unknown flow fails closed ───────────────────────────────────────────
107
+ const unknown = resolveFlow('nope')
108
+ assert(unknown.ok === false && unknown.code === 'UNKNOWN_FLOW', 'unknown flow returns UNKNOWN_FLOW')
109
+ assert(unknown.knownFlows.includes('standard'), 'known flow list is surfaced')
110
+
111
+ // ── 3. core bindings cannot be cancelled by an override ────────────────────
112
+ const merged = resolveFlow('standard', { stage_bindings: { '开发': { rules: [] } } })
113
+ assert(merged.ok && merged.config.stage_bindings['开发'].rules.includes('coding-conventions'),
114
+ 'emptying a stage override keeps the core rule')
115
+
116
+ // ── 4. newTask freezes the snapshot ────────────────────────────────────────
117
+ const snapshot = { flow: 'standard', version: 1, config: FLOW_PRESETS.standard.config }
118
+ const state = newTask({ id: 'T1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
119
+ assert(state.flow?.flow === 'standard' && state.flow.version === 1, 'newTask stores the frozen snapshot')
120
+ assert(state.stage === '需求评审', 'newTask starts at the frozen start stage')
121
+
122
+ // ── 5. high_risk create on minimal is rejected ─────────────────────────────
123
+ await assertThrows(
124
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) }))
125
+ .then(exe => exe({ operation: 'create', task_id: 'T2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)),
126
+ 'high_risk',
127
+ 'high_risk on minimal is rejected',
128
+ )
129
+
130
+ // ── 6. a task keeps its frozen snapshot after eng.json changes ─────────────
131
+ {
132
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
133
+ const exe = await registered(fs)
134
+ const created = await exe({ operation: 'create', task_id: 'T3', title: 'x', branch: 'main' }, EXEC)
135
+ assert(created.includes('需求评审'), 'standard create starts at 需求评审')
136
+ fs._files.set('.dsh/eng.json', JSON.stringify({ flow: 'minimal' }))
137
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'T3' }, EXEC))
138
+ assert(status.flow?.flow === 'standard' && status.flow.version === 2, 'status re-reads the frozen snapshot')
139
+ assert(Array.isArray(status.legal_next) && status.legal_next.includes('设计'),
140
+ 'gates come from the frozen standard flow, not the live minimal flow')
141
+ }
142
+
143
+ // ── 7. unknown flow blocks state-changing operations ───────────────────────
144
+ await assertThrows(
145
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'nope' }) }))
146
+ .then(exe => exe({ operation: 'create', task_id: 'X', title: 'y', branch: 'z' }, EXEC)),
147
+ 'unknown flow',
148
+ 'create on an unknown flow fails closed',
149
+ )
150
+
151
+ // ── 8. a file missing its flow field fails closed ──────────────────────────
152
+ await assertThrows(
153
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({}) }))
154
+ .then(exe => exe({ operation: 'create', task_id: 'X', title: 'y', branch: 'z' }, EXEC)),
155
+ 'missing a "flow" field',
156
+ 'eng.json without a flow field is a config error',
157
+ )
158
+
159
+ // ── 9. init: inspect → propose → apply, no direct overwrite ────────────────
160
+ {
161
+ const fs = makeFs()
162
+ const exe = await registered(fs)
163
+ const insp = await exe({ operation: 'init', phase: 'inspect' }, EXEC)
164
+ assert(insp.includes('no AGENTS.md'), 'inspect on empty project prompts a scan')
165
+ const prop = await exe({ operation: 'init', phase: 'propose', content: '# 项目\n示例' }, EXEC)
166
+ assert(prop.includes('proposed create'), 'propose previews without writing')
167
+ assert(fs._files.get('AGENTS.md') === undefined, 'propose writes nothing')
168
+ const hash9 = /content hash: ([0-9a-f]{64})/.exec(prop)[1]
169
+ const app = await exe({ operation: 'init', phase: 'apply', content: '# 项目\n示例', expected_hash: hash9 }, EXEC)
170
+ assert(app.includes('wrote ./AGENTS.md'), 'apply writes the file')
171
+ assert(fs._files.get('AGENTS.md') === '# 项目\n示例', 'apply persisted the draft')
172
+ }
173
+
174
+ // ── 10. init protects an existing governance file ─────────────────────────
175
+ {
176
+ const fs = makeFs({ 'AGENTS.md': '# 已有治理文件\n' })
177
+ const exe = await registered(fs)
178
+ const prop10 = await exe({ operation: 'init', phase: 'propose', content: '# new' }, EXEC)
179
+ const hash10 = /content hash: ([0-9a-f]{64})/.exec(prop10)[1]
180
+ const existingHash10 = /existing hash: ([0-9a-f]{64})/.exec(prop10)[1]
181
+ assert(existingHash10 !== undefined, 'propose returns the existing-file hash when the file exists')
182
+ await assertThrows(
183
+ () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10 }, EXEC),
184
+ 'requires existing_hash',
185
+ 'apply without existing_hash on an existing file is rejected',
186
+ )
187
+ await assertThrows(
188
+ () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10, existing_hash: '0'.repeat(64) }, EXEC),
189
+ 'existing_hash mismatch',
190
+ 'apply with a stale existing_hash is rejected (TOCTOU)',
191
+ )
192
+ await assertThrows(
193
+ () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10, existing_hash: existingHash10 }, EXEC),
194
+ 'protected',
195
+ 'apply without overwrite on an existing file is rejected',
196
+ )
197
+ await assertThrows(
198
+ () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10, existing_hash: existingHash10, overwrite: true }, EXEC),
199
+ 'approval',
200
+ 'overwrite requires an approval service',
201
+ )
202
+ await assertThrows(
203
+ () => exe({ operation: 'init', phase: 'apply', content: '# new' }, EXEC),
204
+ 'requires expected_hash',
205
+ 'apply without expected_hash is rejected',
206
+ )
207
+ }
208
+
209
+ // ── 11. a project cannot shadow a bundled core rule ────────────────────────
210
+ {
211
+ const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
212
+ assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
213
+ const fs = makeFs({
214
+ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }),
215
+ '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
216
+ })
217
+ const exe = await registered(fs)
218
+ await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
219
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
220
+ const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
221
+ assert(sec !== undefined, 'security-redlines is disclosed at the start stage')
222
+ assert(!sec.content.includes('前端校验即可'), 'a project shadow of a core rule is ignored')
223
+ assert(sec.content.includes('服务端'), 'the bundled core rule body wins over a shadow')
224
+ }
225
+
226
+ // ── 12. session cwd drives every relative path (init regression) ──────────
227
+ {
228
+ const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
229
+ const fs = makeFs({
230
+ [`${cwd}/AGENTS.md`]: '# e2e 项目治理文件\n两行\n',
231
+ 'D:/dsharness/deepseek-harness/AGENTS.md': '# DeepSeek Harness monorepo (packages/pnpm)\n',
232
+ })
233
+ const exe = await registered(fs)
234
+ const insp = await exe({ operation: 'init', phase: 'inspect' }, sessionExec(cwd))
235
+ assert(insp.includes('# e2e 项目治理文件'), 'inspect reads the session workspace AGENTS.md')
236
+ assert(!insp.includes('monorepo'), 'inspect does not read the harness repo AGENTS.md')
237
+ }
238
+
239
+ // ── 13. create writes the task record into the session workspace ──────────
240
+ {
241
+ const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
242
+ const fs = makeFs({ [`${cwd}/.dsh/eng.json`]: JSON.stringify({ flow: 'standard' }) })
243
+ const exe = await registered(fs)
244
+ await exe({ operation: 'create', task_id: 'T12', title: 'x', branch: 'main' }, sessionExec(cwd))
245
+ assert(fs._files.get(`${cwd}/.dsh/task-T12.json`) !== undefined, 'task record lands in the session workspace')
246
+ assert(fs._files.get('.dsh/task-T12.json') === undefined, 'task record does not land in the backend base')
247
+ }
248
+
249
+ // ── 14. K: artifact ids must be unique across ALL stages ────────────────────
250
+ {
251
+ const withDup = { ...FLOW_PRESETS.standard.config, artifacts: [
252
+ { stage: '需求评审', id: 'doc', name: '需求', fields: ['scope'] },
253
+ { stage: '设计', id: 'doc', name: '设计', fields: ['approach'] },
254
+ ] }
255
+ const problems = validateWorkflow(withDup)
256
+ assert(problems.some(p => p.includes('duplicate artifact id "doc"')),
257
+ 'cross-stage duplicate artifact id is rejected')
258
+ }
259
+
260
+ // ── 15. K: record rejects an artifact owned by another stage ─────────────────
261
+ {
262
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
263
+ const exe = await registered(fs)
264
+ await exe({ operation: 'create', task_id: 'K1', title: 'x', branch: 'main' }, EXEC)
265
+ await assertThrows(
266
+ () => exe({ operation: 'record', task_id: 'K1', artifact: 'design', fields: { approach: 'a', risks: 'r', impact: 'i' } }, EXEC),
267
+ 'belongs to stage',
268
+ 'recording an artifact at a non-owning stage is rejected',
269
+ )
270
+ }
271
+
272
+ // ── 16. L/4.2: high-risk verification requires a REAL command receipt ────────
273
+ {
274
+ const cfg = FLOW_PRESETS.standard.config
275
+ const receipt = overrides => ({
276
+ command: 'npm test', exit_code: 0, timed_out: false, aborted: false,
277
+ started_at: '2026-01-01T00:00:00Z', finished_at: '2026-01-01T00:00:01Z',
278
+ stdout: '', stderr: '', ...overrides,
279
+ })
280
+ const mk = () => {
281
+ const s = newTask({ id: 'L1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'high_risk', flow: snapshot })
282
+ s.stage = '交付'
283
+ return s
284
+ }
285
+ let s = mk(); s.verification = { passed: true, evidence: ['单测通过'] }
286
+ assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with text-only evidence is blocked (no command receipt)')
287
+ s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({}) }
288
+ assert(assertAdvance(s, '代码审核', cfg).ok, 'high_risk with an exit-0 receipt passes the verified gate')
289
+ s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({ exit_code: 1 }) }
290
+ assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with a non-zero exit receipt is blocked')
291
+ s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({ timed_out: true }) }
292
+ assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with a timed-out receipt is blocked')
293
+ s = mk(); s.verification = { passed: true, evidence: [], receipt: receipt({ aborted: true }) }
294
+ assert(!assertAdvance(s, '代码审核', cfg).ok, 'high_risk with an aborted receipt is blocked')
295
+ }
296
+
297
+ // ── 17. J: task id extracted from the summary (the hook uses it, not a guess) ─
298
+ {
299
+ const cfg = FLOW_PRESETS.standard.config
300
+ assert(taskIdFromMessage('【GREET-001】【TASK】实现登录', cfg) === 'GREET-001', 'standard summary yields its task id')
301
+ assert(taskIdFromMessage('【GREET-001】【T1】实现登录', cfg) === 'GREET-001', 'item-label summary yields its task id')
302
+ assert(taskIdFromMessage('实现登录', cfg) === undefined, 'non-matching summary yields no task id')
303
+ assert(taskIdFromMessage('随意', FLOW_PRESETS.minimal.config) === undefined, 'pattern-less flow yields no task id')
304
+ }
305
+
306
+ // ── 18. N: engine bookkeeping files are exempt from file scope ───────────────
307
+ {
308
+ const s = newTask({ id: 'N1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
309
+ s.files = ['src/a.ts']
310
+ assert(checkFileScope(s, ['src/a.ts', '.dsh/task-N1.json', '.dsh/eng.json'], FLOW_PRESETS.standard.config).ok,
311
+ 'task record and eng.json do not violate file scope')
312
+ assert(!checkFileScope(s, ['src/b.ts'], FLOW_PRESETS.standard.config).ok,
313
+ 'an out-of-scope code file is still flagged')
314
+ }
315
+
316
+ // ── 19. H/I: hook reads raw-byte paths, NUL-separated, including deletions ───
317
+ {
318
+ const hook = readFileSync('./hooks/commit-msg', 'utf8')
319
+ assert(hook.includes('-c core.quotePath=false'), 'hook disables git octal path quoting')
320
+ assert(hook.includes('-z'), 'hook reads NUL-separated raw-byte paths')
321
+ assert(hook.includes('--diff-filter=ACMRDT'), 'hook includes deletions and type changes in the scope check')
322
+ }
323
+
324
+ // ── 20. 4.1/4.3: hook uses the frozen snapshot; writeInit guards overwrite ──
325
+ {
326
+ const hook = readFileSync('./hooks/commit-msg', 'utf8')
327
+ assert(hook.includes('config = state.flow.config'), 'hook checks the task frozen flow snapshot')
328
+ assert(hook.includes('function extractTaskId'), 'hook extracts the task id independent of the live config')
329
+ assert(!hook.includes('const FLOWS'), 'hook bundles the single source (workflows.ts), not a hand mirror')
330
+ }
331
+ {
332
+ const controller = readFileSync('./lib/controller.js', 'utf8')
333
+ assert(controller.includes('AGENTS.md 已存在且受保护'), 'workbench writeInit refuses to overwrite without an explicit intent')
334
+ assert(controller.includes('overwrite'), 'writeInit request carries an overwrite field')
335
+ }
336
+
337
+ // ── 21. 0.21-A: project adapter — type detection, root discovery, default verify commands ──
338
+ const project = await import('./lib/project.js')
339
+
340
+ function memProbe(files) {
341
+ return {
342
+ async read(dir, relPath) {
343
+ const key = `${dir.replace(/\\/g, '/')}/${relPath}`.replace(/\/+/g, '/')
344
+ return files[key]
345
+ },
346
+ }
347
+ }
348
+
349
+ {
350
+ const probe = memProbe({ '/repo/package.json': '{}' })
351
+ assert(await project.detectType(probe, '/repo') === 'node', 'node marker resolves to node')
352
+ assert(await project.detectRoot(probe, '/repo/src/nested') === '/repo', 'root discovery climbs to the package.json directory')
353
+ }
354
+ {
355
+ const probe = memProbe({ '/repo/pom.xml': '<project/>' })
356
+ assert(await project.detectType(probe, '/repo') === 'java', 'pom.xml resolves to java')
357
+ }
358
+ {
359
+ const probe = memProbe({ '/repo/go.mod': 'module x' })
360
+ assert(await project.detectType(probe, '/repo') === 'go', 'go.mod resolves to go')
361
+ }
362
+ {
363
+ const probe = memProbe({ '/repo/Cargo.toml': '[package]' })
364
+ assert(await project.detectType(probe, '/repo') === 'rust', 'Cargo.toml resolves to rust')
365
+ }
366
+ {
367
+ const probe = memProbe({ '/repo/requirements.txt': 'flask' })
368
+ assert(await project.detectType(probe, '/repo') === 'python', 'requirements.txt resolves to python')
369
+ }
370
+ {
371
+ const probe = memProbe({})
372
+ assert(await project.detectType(probe, '/empty') === 'unknown', 'empty tree reports unknown type')
373
+ assert(await project.detectRoot(probe, '/empty/deep/dir') === '/empty/deep/dir', 'root discovery falls back to the start directory')
374
+ }
375
+ {
376
+ const probe = memProbe({ '/repo/package.json': '{}', '/repo/pyproject.toml': '[tool]' })
377
+ assert(await project.detectType(probe, '/repo') === 'unknown', 'mixed tree reports unknown, never a silent single-language guess')
378
+ }
379
+ {
380
+ const probe = memProbe({ '/repo/.git/HEAD': 'ref: refs/heads/main' })
381
+ assert(await project.detectRoot(probe, '/repo/sub/module') === '/repo', 'root discovery stops at the git repository')
382
+ }
383
+ {
384
+ assert(project.defaultVerifyCommand('node') === 'npm test', 'node default verify command')
385
+ assert(project.defaultVerifyCommand('java') === 'mvn -q test', 'java default verify command')
386
+ assert(project.defaultVerifyCommand('go') === 'go test ./...', 'go default verify command')
387
+ assert(project.defaultVerifyCommand('unknown') === undefined, 'unknown type has no default command')
388
+ }
389
+ {
390
+ const probe = memProbe({ '/repo/AGENTS.md': '# a', '/repo/CLAUDE.md': '# c' })
391
+ const present = await project.presentGovernanceFiles(probe, '/repo')
392
+ assert(present.includes('AGENTS.md') && present.includes('CLAUDE.md'), 'governance files are recognized as present')
393
+ assert(!present.includes('.cursorrules'), 'absent governance files are not listed')
394
+ }
395
+
396
+ // ── 22. 0.21-C/D/E: out-of-root write guard, hook risk policy, project binding dirs ──
397
+ {
398
+ const { assertInsideRoot } = await import('./lib/dev-task.js')
399
+ assertInsideRoot('/repo', '/repo/sub', 'AGENTS.md')
400
+ assertInsideRoot('/repo/sub', '/repo/sub', 'AGENTS.md')
401
+ await assertThrows(
402
+ () => assertInsideRoot('/repo', '/repo', '../outside.txt'),
403
+ 'escapes the project root',
404
+ 'init write escaping the root is rejected',
405
+ )
406
+ await assertThrows(
407
+ () => assertInsideRoot('/repo', '/repo', 'C:/elsewhere/x.txt'),
408
+ 'escapes the project root',
409
+ 'absolute target outside the root is rejected',
410
+ )
411
+ }
412
+ {
413
+ const hook = readFileSync('./hooks/commit-msg', 'utf8')
414
+ assert(hook.includes('sensitive_paths'), 'hook bundles the risk policy table (single source)')
415
+ assert(hook.includes('risk_level === "high_risk"') || hook.includes('risk_level === \'high_risk\''), 'hook requires high_risk before touching sensitive paths')
416
+ }
417
+ {
418
+ const probe = {
419
+ async read(dir, relPath) {
420
+ const key = `${dir}/${relPath}`.replace(/\/+/g, '/')
421
+ return files[key]
422
+ },
423
+ async list(dir, relPath) {
424
+ const prefix = `${dir}/${relPath}`.replace(/\/+/g, '/')
425
+ return Object.keys(files).filter(k => k.startsWith(prefix + '/')).map(k => k.slice(prefix.length + 1).split('/')[0])
426
+ },
427
+ }
428
+ const files = {
429
+ '/repo/.dsh/rules/api-conventions.md': '# r',
430
+ '/repo/.dsh/rules/README.md': '# meta',
431
+ '/repo/.dsh/skills/java-dev/SKILL.md': '# s',
432
+ '/repo/.dsh/skills/empty/': '# no skill file',
433
+ }
434
+ const rules = await project.listProjectRules(probe, '/repo')
435
+ assert(rules.length === 2 && rules.includes('api-conventions'), 'project rules list from .dsh/rules/*.md')
436
+ const skills = await project.listProjectSkills(probe, '/repo')
437
+ assert(skills.length === 1 && skills.includes('java-dev'), 'project skills list only dirs carrying SKILL.md')
438
+ }
439
+
440
+ // ── 23. 0.22-A: snapshot hash integrity ────────────────────────────────────
441
+ {
442
+ const { hashConfig } = await import('./lib/snapshot.js')
443
+ const config = FLOW_PRESETS.standard.config
444
+ const h1 = hashConfig(config)
445
+ const h2 = hashConfig(JSON.parse(JSON.stringify(config)))
446
+ assert(h1 === h2 && /^[0-9a-f]{64}$/.test(h1), 'hashConfig is a deterministic sha256')
447
+ const tampered = JSON.parse(JSON.stringify(config))
448
+ tampered.commit.file_scope = false
449
+ assert(hashConfig(tampered) !== h1, 'editing the frozen config changes the hash')
450
+ }
451
+ {
452
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
453
+ const exe = await registered(fs)
454
+ await exe({ operation: 'create', task_id: 'HASH-1', title: 'x', branch: 'main' }, EXEC)
455
+ const record = JSON.parse(fs._files.get('.dsh/task-HASH-1.json'))
456
+ record.flow.config.commit.file_scope = false
457
+ fs._files.set('.dsh/task-HASH-1.json', JSON.stringify(record))
458
+ await assertThrows(
459
+ () => exe({ operation: 'status', task_id: 'HASH-1' }, EXEC),
460
+ 'hash mismatch',
461
+ 'status on a tampered snapshot is rejected',
462
+ )
463
+ }
464
+ {
465
+ const hook = readFileSync('./hooks/commit-msg', 'utf8')
466
+ assert(hook.includes('hashConfig'), 'hook reuses the single hashConfig source')
467
+ assert(hook.includes('sha256') || hook.includes('createHash'), 'hook bundles the sha256 snapshot check')
468
+ }
469
+
470
+ // ── 24. 0.22-B: installed-hook integrity check ─────────────────────────────
471
+ {
472
+ const fs = makeFs({})
473
+ const exe = await registered(fs)
474
+ assert((await exe({ operation: 'verify_hook' }, EXEC)).includes('run install_hook'), 'verify_hook reports an absent hook')
475
+ await exe({ operation: 'install_hook' }, EXEC)
476
+ assert((await exe({ operation: 'verify_hook' }, EXEC)).includes('integrity OK'), 'verify_hook passes on the bundled hook')
477
+ fs._files.set('.git/hooks/commit-msg', fs._files.get('.git/hooks/commit-msg') + '\n// tampered')
478
+ await assertThrows(
479
+ () => exe({ operation: 'verify_hook' }, EXEC),
480
+ 'integrity FAILED',
481
+ 'verify_hook rejects a tampered hook',
482
+ )
483
+ }
484
+
485
+ // ── 25. 0.22-B: risk downgrade approval + bindings fingerprint ─────────────
486
+ {
487
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
488
+ const exe = await registered(fs)
489
+ await exe({ operation: 'create', task_id: 'RISK-1', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
490
+ await assertThrows(
491
+ () => exe({ operation: 'set_risk', task_id: 'RISK-1', risk_level: 'standard' }, EXEC),
492
+ 'approval',
493
+ 'downgrade without an approval service is rejected',
494
+ )
495
+ assert((await exe({ operation: 'set_risk', task_id: 'RISK-1', risk_level: 'high_risk' }, EXEC)).includes('already'),
496
+ 'same-risk set_risk is a no-op')
497
+ }
498
+ {
499
+ const approval = { async request() { return 'allowed-once' } }
500
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
501
+ const exe = await registered(fs, approval)
502
+ await exe({ operation: 'create', task_id: 'RISK-2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
503
+ assert((await exe({ operation: 'set_risk', task_id: 'RISK-2', risk_level: 'standard' }, EXEC)).includes('risk_level set to standard'),
504
+ 'approved downgrade succeeds')
505
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'RISK-2' }, EXEC))
506
+ assert(status.risk_level === 'standard' && status.risk_downgrades.length === 1,
507
+ 'downgrade is recorded in the audit trail')
508
+ }
509
+ {
510
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
511
+ const exe = await registered(fs)
512
+ await exe({ operation: 'create', task_id: 'FP-1', title: 'x', branch: 'main' }, EXEC)
513
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'FP-1' }, EXEC))
514
+ assert(status.bindings_drift === undefined, 'fresh task shows no bindings drift')
515
+ const record = JSON.parse(fs._files.get('.dsh/task-FP-1.json'))
516
+ assert(typeof record.bindings_fingerprint === 'string' && record.bindings_fingerprint.length === 64,
517
+ 'create records the bundled-rules fingerprint')
518
+ }
519
+
520
+ // ── 26. 0.22-C: multi-language verify command chain + parallel tasks ────────
521
+ {
522
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard', verify_command: 'mvn test' }) })
523
+ const exe = await registered(fs)
524
+ await exe({ operation: 'create', task_id: 'V-1', title: 'x', branch: 'main' }, EXEC)
525
+ await assertThrows(
526
+ () => exe({ operation: 'verify', task_id: 'V-1' }, EXEC),
527
+ 'shell',
528
+ 'verify falls back to the eng.json command and fails loud without a shell service',
529
+ )
530
+ }
531
+ {
532
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
533
+ const exe = await registered(fs)
534
+ await exe({ operation: 'create', task_id: 'V-2', title: 'x', branch: 'main' }, EXEC)
535
+ await assertThrows(
536
+ () => exe({ operation: 'verify', task_id: 'V-2' }, EXEC),
537
+ 'shell',
538
+ 'verify falls back to the language default (node → npm test) and fails loud without a shell service',
539
+ )
540
+ }
541
+ {
542
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
543
+ const exe = await registered(fs)
544
+ await exe({ operation: 'create', task_id: 'V-3', title: 'x', branch: 'main' }, EXEC)
545
+ await assertThrows(() => exe({ operation: 'verify', task_id: 'V-3', passed: true }, EXEC),
546
+ 'real validation command', 'new tasks refuse self-reported verification')
547
+ const legacy = JSON.parse(fs._files.get('.dsh/task-V-3.json'))
548
+ delete legacy.execution_version
549
+ fs._files.set('.dsh/task-V-3.json', JSON.stringify(legacy))
550
+ assert(JSON.parse(await exe({ operation: 'verify', task_id: 'V-3', passed: true }, EXEC)).ok,
551
+ 'legacy tasks retain their verification contract')
552
+ }
553
+ {
554
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
555
+ const exe = await registered(fs)
556
+ await exe({ operation: 'create', task_id: 'A-1', title: 'x', branch: 'main' }, EXEC)
557
+ await exe({ operation: 'create', task_id: 'B-2', title: 'y', branch: 'main' }, EXEC)
558
+ await exe({ operation: 'scope', task_id: 'A-1', files: ['src/a.ts'] }, EXEC)
559
+ const a = JSON.parse(await exe({ operation: 'status', task_id: 'A-1' }, EXEC))
560
+ const b = JSON.parse(await exe({ operation: 'status', task_id: 'B-2' }, EXEC))
561
+ assert(a.files.length === 1 && b.files.length === 0, 'two tasks in one repo advance independently')
562
+ }
563
+
564
+ // ── 27. 9.4: init apply validates the expected hash ─────────────────────────
565
+ {
566
+ const fs = makeFs({})
567
+ const exe = await registered(fs)
568
+ const propose = await exe({ operation: 'init', phase: 'propose', content: 'hello' }, EXEC)
569
+ const match = /content hash: ([0-9a-f]{64})/.exec(propose)
570
+ assert(match !== null, 'propose returns the content hash')
571
+ await assertThrows(
572
+ () => exe({ operation: 'init', phase: 'apply', content: 'hello', expected_hash: '0'.repeat(64) }, EXEC),
573
+ 'expected_hash mismatch',
574
+ 'apply with a mismatched hash is rejected',
575
+ )
576
+ assert((await exe({ operation: 'init', phase: 'apply', content: 'hello', expected_hash: match[1] }, EXEC)).includes('wrote'),
577
+ 'apply with the matching hash writes the file')
578
+ }
579
+
580
+ // ── 28. risk policy excludes project binding dirs ───────────────────────────
581
+ {
582
+ const policy = project.DEFAULT_RISK_POLICY
583
+ assert(!policy.sensitive_paths.includes('.dsh'),
584
+ 'project binding dir (.dsh/rules, .dsh/skills) stays committable by normal tasks')
585
+ assert(policy.sensitive_paths.includes('.env'), 'env files remain sensitive')
586
+ }
587
+
588
+ // ── 29. dev_task writes carry the per-call sandbox policy ───────────────────
589
+ {
590
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
591
+ let seenPolicy
592
+ const origWrite = fs.writeText
593
+ fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
594
+ seenPolicy = sandboxPolicy
595
+ return origWrite(target, content)
596
+ }
597
+ const exe = await registered(fs)
598
+ await exe({ operation: 'create', task_id: 'POL-1', title: 'x', branch: 'main' }, EXEC)
599
+ assert(seenPolicy !== undefined && seenPolicy.mode === 'workspace-write',
600
+ 'dev_task writes carry the sandbox mode (default workspace-write)')
601
+ assert(seenPolicy.workspaceRoot === process.cwd(),
602
+ 'dev_task writes carry the session workspace root so in-workspace paths are never misjudged as out-of-root')
603
+ await assertThrows(
604
+ () => exe({ operation: 'create', task_id: 'POL-2', title: 'x', branch: 'main', sandbox_permissions: 'danger-full-access' }, EXEC),
605
+ 'requires a justification',
606
+ 'escalation without a justification is rejected',
607
+ )
608
+ }
609
+
610
+ // ── 30. 0.22.x: sandbox escalation requires a human one-shot approval ───────
611
+ {
612
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
613
+ let seenPolicy
614
+ const origWrite = fs.writeText
615
+ fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
616
+ seenPolicy = sandboxPolicy
617
+ return origWrite(target, content)
618
+ }
619
+ const approval = { async request() { return 'allowed-once' } }
620
+ const exe = await registered(fs, approval)
621
+ await exe({
622
+ operation: 'create', task_id: 'POL-3', title: 'x', branch: 'main',
623
+ sandbox_permissions: 'danger-full-access', justification: 'the task record must land despite the sandbox denial',
624
+ }, EXEC)
625
+ assert(seenPolicy.mode === 'danger-full-access',
626
+ 'approved escalation upgrades the per-call write mode')
627
+ }
628
+
629
+ {
630
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
631
+ const exe = await registered(fs) // no approval service
632
+ await assertThrows(
633
+ () => exe({
634
+ operation: 'create', task_id: 'POL-4', title: 'x', branch: 'main',
635
+ sandbox_permissions: 'danger-full-access', justification: 'why',
636
+ }, EXEC),
637
+ 'approval service is unavailable',
638
+ 'escalation without an approval service fails closed',
639
+ )
640
+ }
641
+
642
+ // ── 31. 0.22.x: set_risk cannot raise risk past the flow's capabilities ─────
643
+ {
644
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) })
645
+ const exe = await registered(fs)
646
+ await exe({ operation: 'create', task_id: 'SR-1', title: 'x', branch: 'main' }, EXEC)
647
+ await assertThrows(
648
+ () => exe({ operation: 'set_risk', task_id: 'SR-1', risk_level: 'high_risk' }, EXEC),
649
+ 'lacks the required capabilities',
650
+ 'raising risk on a minimal flow is rejected (same gate as create)',
651
+ )
652
+ }
653
+ {
654
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
655
+ const exe = await registered(fs)
656
+ await exe({ operation: 'create', task_id: 'SR-2', title: 'x', branch: 'main' }, EXEC)
657
+ assert((await exe({ operation: 'set_risk', task_id: 'SR-2', risk_level: 'high_risk' }, EXEC)).includes('high_risk'),
658
+ 'raising risk on the standard flow succeeds')
659
+ }
660
+
661
+ // ── 32. 0.22.x: remote rejects non-absolute / system-root workspace paths ───
662
+ {
663
+ const controller = readFileSync('./lib/controller.js', 'utf8')
664
+ assert(controller.includes('workspace path must be absolute'), 'remote rejects non-absolute workspace paths')
665
+ assert(controller.includes('not an allowed workspace'), 'remote rejects system-wide roots')
666
+ }
667
+
668
+ // ── 33. 0.23: verify runs in the task's recorded project root ──────────────
669
+ {
670
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
671
+ const seenWorkdirs = []
672
+ const shell = {
673
+ resolve(request) { return { command: request.command, workdir: request.workdir ?? '', timeoutMs: request.timeoutMs } },
674
+ async run(spec) { seenWorkdirs.push(spec.workdir); return { exitCode: 0, timedOut: false, aborted: false, stdout: { text: 'ok' }, stderr: { text: '' } } },
675
+ }
676
+ const ctx = makeCtx(fs, undefined)
677
+ ctx.get = (service) => (service === 'shell' ? shell : service === 'approval' ? undefined : undefined)
678
+ registerDevTask(ctx)
679
+ const exe = ctx._tools[0].execute
680
+ await exe({ operation: 'create', task_id: 'VR-1', title: 'x', branch: 'main' }, EXEC)
681
+ await exe({ operation: 'verify', task_id: 'VR-1' }, EXEC)
682
+ const record = JSON.parse(fs._files.get('.dsh/task-VR-1.json'))
683
+ assert(seenWorkdirs.length === 1, 'bare verify ran the default command')
684
+ assert(seenWorkdirs[0] === (record.root ?? ''), 'verify ran in the recorded project root, not the session cwd')
685
+ assert(record.verification.receipt.root === record.root, 'receipt binds the recorded project root')
686
+ }
687
+
688
+ // ── 34. 0.23: revision increments on every write (CAS foundation) ───────────
689
+ {
690
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
691
+ const exe = await registered(fs)
692
+ await exe({ operation: 'create', task_id: 'CAS-1', title: 'x', branch: 'main' }, EXEC)
693
+ const r1 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
694
+ await exe({ operation: 'scope', task_id: 'CAS-1', files: ['a'] }, EXEC)
695
+ const r2 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
696
+ assert(r1 === 1 && r2 === 2, 'revision increments on every write')
697
+ const devtask = readFileSync('./lib/dev-task.js', 'utf8')
698
+ assert(devtask.includes('changed concurrently'), 'concurrent-write CAS guard is present')
699
+ }
700
+
701
+ // ── 35. 0.23: Remote path guard + workspace registry ────────────────────────
702
+ {
703
+ const ctl = await import('./lib/controller.js')
704
+ assert(ctl.checkedPath('D:/proj/one') === 'D:/proj/one', 'absolute workspace passes')
705
+ assert(ctl.checkedPath('D:/proj/one/') === 'D:/proj/one', 'trailing slash is normalized')
706
+ await assertThrows(() => ctl.checkedPath('relative/path'), 'must be absolute', 'relative path is rejected')
707
+ await assertThrows(() => ctl.checkedPath('C:/Windows/system32'), 'not an allowed workspace', 'system root is rejected')
708
+ await assertThrows(() => ctl.checkedPath('c:/windows'), 'not an allowed workspace', 'case variant of a system root is rejected')
709
+ ctl.registerWorkspace('D:/proj/one')
710
+ assert(ctl.checkedPath('D:/PROJ/one') === 'D:/PROJ/one', 'registered workspace passes under any case')
711
+ await assertThrows(() => ctl.checkedPath('D:/other-project'), 'not registered', 'unregistered workspace is rejected once the registry is active')
712
+ ctl.enableStrictWorkspaces()
713
+ await assertThrows(() => ctl.checkedPath('D:/still-unregistered'), 'not registered', 'strict mode keeps rejecting unregistered workspaces')
714
+ assert(ctl.checkedPath('D:/proj/one') === 'D:/proj/one', 'strict mode still admits registered workspaces')
715
+ }
716
+
717
+
718
+ // A second writer between content read and write must not be silently overwritten.
719
+ {
720
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
721
+ const exe = await registered(fs)
722
+ await exe({ operation: 'create', task_id: 'CAS-RACE', title: 'race', branch: 'main' }, EXEC)
723
+ const originalRead = fs.readText.bind(fs)
724
+ let taskReads = 0
725
+ fs.readText = async target => {
726
+ const value = await originalRead(target)
727
+ if (target.targetKey === '.dsh/task-CAS-RACE.json' && ++taskReads === 2) {
728
+ const other = JSON.parse(value)
729
+ other.title = 'concurrent writer'
730
+ other.revision++
731
+ await fs.writeText(target, JSON.stringify(other))
732
+ }
733
+ return value
734
+ }
735
+ await assertThrows(() => exe({ operation: 'scope', task_id: 'CAS-RACE', files: ['a'] }, EXEC), 'changed concurrently', 'CAS rejects writes when version changes after the content read')
736
+ assert(JSON.parse(fs._files.get('.dsh/task-CAS-RACE.json')).title === 'concurrent writer', 'CAS preserves the concurrent writer record')
737
+ }
738
+
739
+ console.log(`\nP0 acceptance: ${passed} checks passed`)