@godv61/dsh-task-engine 0.22.7 → 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 (68) hide show
  1. package/.p0-test.mjs +739 -748
  2. package/.resource-test.mjs +133 -0
  3. package/.workflow-test.mjs +234 -0
  4. package/LICENSE +21 -0
  5. package/README.md +110 -207
  6. package/cordis.patch.yml +10 -10
  7. package/defaults/eng.json +10 -10
  8. package/docs/CHANGELOG.md +185 -0
  9. package/docs/README.md +27 -0
  10. package/docs/assets/workflow-banner.svg +29 -0
  11. package/docs/configuration.md +49 -0
  12. package/docs/development.md +53 -0
  13. package/docs/faq.md +43 -0
  14. package/docs/getting-started.md +51 -0
  15. package/docs/listing/godv61__dsh-task-engine.yml +6 -0
  16. package/docs/listing/submission.md +9 -0
  17. package/docs/manual.html +391 -382
  18. package/docs/release-0.23.0.md +32 -0
  19. package/docs/release-0.23.1.md +58 -0
  20. package/docs/resource-install.md +62 -0
  21. package/docs/roadmap.md +33 -0
  22. package/docs/testing/0.23.0//346/265/213/350/257/225/346/211/247/350/241/214/350/256/260/345/275/225.md +189 -0
  23. package/docs/testing/0.23.0//346/265/213/350/257/225/346/212/245/345/221/212.md +42 -0
  24. package/docs/testing/0.23.1//346/265/213/350/257/225/346/212/245/345/221/212.md +34 -0
  25. 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
  26. package/docs/workflow-regression.md +36 -0
  27. package/hooks/commit-msg +4 -4
  28. package/lib/client.js +555 -868
  29. package/lib/client.js.map +4 -4
  30. package/lib/controller.d.ts +19 -0
  31. package/lib/controller.js +86 -137
  32. package/lib/controller.js.map +1 -1
  33. package/lib/dev-task.js +190 -37
  34. package/lib/dev-task.js.map +1 -1
  35. package/lib/engine.d.ts +19 -1
  36. package/lib/engine.js +5 -0
  37. package/lib/engine.js.map +1 -1
  38. package/lib/resource-import.d.ts +21 -0
  39. package/lib/resource-import.js +210 -0
  40. package/lib/resource-import.js.map +1 -0
  41. package/lib/resource-types.d.ts +25 -0
  42. package/lib/resource-types.js +2 -0
  43. package/lib/resource-types.js.map +1 -0
  44. package/lib/shipped-skills.js +2 -1
  45. package/lib/shipped-skills.js.map +1 -1
  46. package/lib/skill-audit.d.ts +16 -0
  47. package/lib/skill-audit.js +60 -0
  48. package/lib/skill-audit.js.map +1 -0
  49. package/lib/workflows.js +4 -4
  50. package/lib/workflows.js.map +1 -1
  51. package/lib/workspace-access.d.ts +6 -0
  52. package/lib/workspace-access.js +13 -0
  53. package/lib/workspace-access.js.map +1 -0
  54. package/package.json +95 -85
  55. package/preset/agent.cordis.yml +21 -21
  56. package/preset/enable.mjs +87 -87
  57. package/preset/persona.md +4 -4
  58. package/preset/preset.yml +1 -1
  59. package/rules/coding-conventions.md +6 -6
  60. package/rules/commit-conventions.md +5 -5
  61. package/rules/security-redlines.md +5 -5
  62. package/skills/code-commit/SKILL.md +13 -13
  63. package/skills/code-implement/SKILL.md +23 -22
  64. package/skills/code-review/SKILL.md +13 -13
  65. package/skills/code-verify/SKILL.md +16 -11
  66. package/skills/eng-delivery/SKILL.md +38 -34
  67. package/skills/requirement-analysis/SKILL.md +15 -15
  68. package/skills/solution-design/SKILL.md +18 -15
@@ -0,0 +1,133 @@
1
+ import test from 'node:test'
2
+ import fs from 'node:fs'
3
+ import { syncBuiltinESMExports } from 'node:module'
4
+ import assert from 'node:assert/strict'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, symlinkSync, readdirSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { join, resolve, sep } from 'node:path'
8
+ import { prepareImport, commitImport } from './lib/resource-import.js'
9
+
10
+ const file = (path, text) => ({path,base64:Buffer.from(text).toString('base64')})
11
+ const skill = '---\nname: sample-skill\ndescription: A test skill\n---\n# Sample\n'
12
+ const baseRequest = () => ({kind:'skill',level:'project',path:'unused',files:[file('sample/SKILL.md',skill),file('sample/assets/data.bin',Buffer.from([0,128,255]))]})
13
+ function fixture(t) {
14
+ const root=mkdtempSync(join(tmpdir(),'task-engine-import-'))
15
+ t.after(()=> { const full=resolve(root); assert.ok(full.startsWith(resolve(tmpdir())+sep)); rmSync(full,{recursive:true,force:true}) })
16
+ return {root,base:join(root,'skills')}
17
+ }
18
+ test('preview has no filesystem side effects and reports exact bytes',t=>{
19
+ const {base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
20
+ assert.equal(p.name,'sample-skill');assert.equal(p.files,2);assert.equal(p.bytes,Buffer.byteLength(skill)+3);assert.equal(existsSync(base),false)
21
+ })
22
+ test('confirmed install preserves binary assets and refuses duplicates',t=>{
23
+ const {base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
24
+ commitImport({...r,expectedHash:p.hash},base,()=>false)
25
+ assert.deepEqual(readFileSync(join(base,'sample-skill/assets/data.bin')),Buffer.from([0,128,255]))
26
+ assert.throws(()=>commitImport({...r,expectedHash:p.hash},base,()=>false),/同名/)
27
+ })
28
+ test('changed content and changed installation target invalidate preview',t=>{
29
+ const {root,base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
30
+ assert.throws(()=>commitImport({...r,files:[file('SKILL.md',skill+'changed')],expectedHash:p.hash},base,()=>false),/发生变化/)
31
+ assert.throws(()=>commitImport({...r,expectedHash:p.hash},join(root,'other'),()=>false),/发生变化/)
32
+ assert.equal(existsSync(base),false)
33
+ })
34
+ test('path traversal, Windows ADS and reserved names are rejected',t=>{
35
+ const {base}=fixture(t)
36
+ for(const path of ['../escape','/escape','C:/escape','x\\escape','NUL.txt','x:stream','x/../escape','x.']) {
37
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file(path,'bad')]},base,()=>false),/不安全/)
38
+ }
39
+ })
40
+ test('case-colliding filenames are rejected',t=>{
41
+ const {base}=fixture(t)
42
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file('a.txt','a'),file('A.txt','b')]},base,()=>false),/大小写/)
43
+ })
44
+ test('bundled names and ambiguous containers are rejected',t=>{
45
+ const {base}=fixture(t)
46
+ assert.throws(()=>prepareImport(baseRequest(),base,()=>true),/内置/)
47
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('a/SKILL.md',skill),file('b/SKILL.md',skill)]},base,()=>false),/多个技能/)
48
+ })
49
+ test('single-file rule accepts UTF-8 BOM and CRLF; empty rules are refused',t=>{
50
+ const {base}=fixture(t),r={kind:'rule',level:'project',path:'unused',files:[file('sample-rule.md','\uFEFF# Rule\r\nDo it.')]}
51
+ const p=prepareImport(r,base,()=>false).preview
52
+ assert.equal(p.name,'sample-rule');assert.match(p.content,/# Rule/)
53
+ commitImport({...r,expectedHash:p.hash},base,()=>false)
54
+ assert.ok(existsSync(join(base,'sample-rule.md')))
55
+ assert.throws(()=>prepareImport({...r,files:[file('empty.md',' ')]},base,()=>false),/不能为空/)
56
+ })
57
+ test('file and directory prefix conflicts fail during preview',t=>{
58
+ const {base}=fixture(t),r={...baseRequest(),files:[file('SKILL.md',skill),file('a','file'),file('a/b','child')]}
59
+ assert.throws(()=>prepareImport(r,base,()=>false),/路径冲突/)
60
+ assert.equal(existsSync(base),false)
61
+ })
62
+ test('failed installation removes only its own incomplete directory',t=>{
63
+ const {base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
64
+ mkdirSync(base);writeFileSync(join(base,'keep.txt'),'existing')
65
+ const original=fs.writeFileSync
66
+ try {
67
+ fs.writeFileSync=(path,...args)=>{if(String(path).endsWith('data.bin')) throw new Error('injected disk failure');return original(path,...args)}
68
+ syncBuiltinESMExports()
69
+ assert.throws(()=>commitImport({...r,expectedHash:p.hash},base,()=>false),/injected disk failure/)
70
+ } finally { fs.writeFileSync=original;syncBuiltinESMExports() }
71
+ assert.equal(existsSync(p.target),false);assert.deepEqual(readdirSync(base),['keep.txt']);assert.equal(readFileSync(join(base,'keep.txt'),'utf8'),'existing')
72
+ })
73
+
74
+ test('host directory scanner resolves one container child and excludes caches',t=>{
75
+ const {root,base}=fixture(t),src=join(root,'source'),nested=join(src,'sample')
76
+ mkdirSync(join(nested,'node_modules'),{recursive:true});writeFileSync(join(nested,'SKILL.md'),skill);writeFileSync(join(nested,'node_modules','ignore.txt'),'ignore')
77
+ const p=prepareImport({...baseRequest(),files:[],sourceDir:src},base,()=>false).preview
78
+ assert.equal(p.files,1)
79
+ })
80
+ test('host scanner rejects symlink or junction source paths',t=>{
81
+ const {root,base}=fixture(t),src=join(root,'source'),link=join(root,'link')
82
+ mkdirSync(src);writeFileSync(join(src,'SKILL.md'),skill)
83
+ symlinkSync(src,link,process.platform==='win32'?'junction':'dir')
84
+ assert.throws(()=>prepareImport({...baseRequest(),files:[],sourceDir:link},base,()=>false),/符号链接/)
85
+ })
86
+ test('deep directories and oversized files fail before installation',t=>{
87
+ const {base}=fixture(t)
88
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file('a/'.repeat(21)+'b','x')]},base,()=>false),/20 层/)
89
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file('big',Buffer.alloc(20*1024*1024+1))]},base,()=>false),/大小限制|20 MB/)
90
+ assert.equal(existsSync(base),false)
91
+ })
92
+ test('invalid UTF-8, encoding and absent source directories report errors',t=>{
93
+ const {root,base}=fixture(t)
94
+ assert.throws(()=>prepareImport({...baseRequest(),files:[{path:'SKILL.md',base64:'not base64!!'}]},base,()=>false),/编码/)
95
+ assert.throws(()=>prepareImport({kind:'rule',level:'user',path:'unused',files:[file('rule.md',Buffer.from([255]))]},base,()=>false))
96
+ assert.throws(()=>prepareImport({...baseRequest(),files:[],sourceDir:join(root,'missing')},base,()=>false))
97
+ })
98
+
99
+ test('registered host workspace wins over client spelling; unknown paths fail', async () => {
100
+ const {authorizedWorkspace}=await import('./lib/workspace-access.js')
101
+ const host={get:()=>({resolveByPath:async path=>path==='known'?{path:'/canonical/project'}:undefined})}
102
+ assert.equal(await authorizedWorkspace(host,'known',()=>{throw new Error('fallback must not run')}),'/canonical/project')
103
+ await assert.rejects(()=>authorizedWorkspace(host,'unknown',()=>'/unsafe'),/未在 Harness 注册/)
104
+ await assert.rejects(()=>authorizedWorkspace(host,'',()=>'/unsafe'),/请选择/)
105
+ })
106
+ test('legacy host retains its explicit workspace policy', async () => {
107
+ const {authorizedWorkspace}=await import('./lib/workspace-access.js')
108
+ assert.equal(await authorizedWorkspace({get:()=>undefined},'/project',p=>p),'/project')
109
+ await assert.rejects(()=>authorizedWorkspace({get:()=>undefined},'/bad',()=>{throw new Error('denied')}),/denied/)
110
+ })
111
+ test('host registry failures are not downgraded to legacy permissions', async () => {
112
+ const {authorizedWorkspace}=await import('./lib/workspace-access.js')
113
+ await assert.rejects(()=>authorizedWorkspace({get:()=>({resolveByPath:async()=>{throw new Error('registry unavailable')}})},'/project',()=>'/unsafe'),/registry unavailable/)
114
+ })
115
+
116
+ test('ledger shows absent storage as empty and propagates access errors',async()=>{
117
+ const {default:Controller}=await import('./lib/controller.js')
118
+ const controller=Object.create(Controller.prototype)
119
+ controller.authorizedPath=async p=>p
120
+ controller.fs=()=>({resolve:async p=>p,listDir:async()=>{throw Object.assign(new Error('missing'),{code:'ENOENT'})}})
121
+ assert.deepEqual(await controller.readTasks('/project'),{tasks:[]})
122
+ controller.fs=()=>({resolve:async p=>p,listDir:async()=>{throw Object.assign(new Error('denied'),{code:'EACCES'})}})
123
+ await assert.rejects(()=>controller.readTasks('/project'),/denied/)
124
+ })
125
+ test('ledger rejects malformed records instead of pretending there are no tasks',async()=>{
126
+ const {default:Controller}=await import('./lib/controller.js')
127
+ const controller=Object.create(Controller.prototype)
128
+ controller.authorizedPath=async p=>p
129
+ for(const content of ['{broken','null','{}']) {
130
+ controller.fs=()=>({resolve:async p=>p,listDir:async()=>[{name:'task-broken.json'}],readText:async()=>content})
131
+ await assert.rejects(()=>controller.readTasks('/project'),/任务记录/)
132
+ }
133
+ })
@@ -0,0 +1,234 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve, sep } from 'node:path'
6
+ import { registerDevTask } from './lib/dev-task.js'
7
+ import { newTask } from './lib/engine.js'
8
+ import { resolveFlow } from './lib/workflows.js'
9
+ import Controller from './lib/controller.js'
10
+ import { registerShippedSkills } from './lib/shipped-skills.js'
11
+
12
+ function fixture(stage = '开发', extra = {}) {
13
+ const cwd = resolve('test-project')
14
+ const config = resolveFlow('standard', { stage_bindings: { 完成: { skills: ['software-testing'] } } }).config
15
+ const state = newTask({ id: 'LIVE-1', title: 'EAM regression', branch: 'test', work_size: 'standard', risk_level: 'standard', flow: { flow: 'standard', version: 2, config }, root: cwd })
16
+ Object.assign(state, { stage, execution_version: 1, files: ['app.js'], requirement_confirmed: true, solution_confirmed: true, ...extra })
17
+ const records = new Map([[join(cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state)], [join(cwd, 'app.js'), 'source']])
18
+ const events = [], runs = []
19
+ const session = { id: 'test-session', header: { cwd }, snapshotEvents: () => events }
20
+ const policy = { mode: 'workspace-write', workspaceRoot: cwd, sessionId: session.id }
21
+ const abort = new AbortController()
22
+ let execute
23
+ const key = (path, options) => join(options?.cwd ?? cwd, path)
24
+ const fs = {
25
+ resolve: async (path, options) => ({ targetKey: key(path, options) }),
26
+ readText: async target => records.get(target.targetKey),
27
+ listDir: async () => [...records.keys()].filter(path => /task-.+\.json$/.test(path)).map(path => ({ name: path.split(/[\\/]/).at(-1) })),
28
+ lstat: async (path, options) => records.has(key(path, options)) ? { version: 'v' } : undefined,
29
+ writeText: async (target, content) => { records.set(target.targetKey, content) },
30
+ }
31
+ const ctx = { fs, tools: { register(tool) { execute = tool.execute; return () => {} } }, get(name) {
32
+ if (name === 'sandboxPolicy') return { resolve(request) { assert.equal(request.session, session); return policy } }
33
+ if (name === 'shell') return { resolve(request) { runs.push(request); return request }, async run(request) {
34
+ const fail = request.command === 'fail'
35
+ return { exitCode: fail ? 1 : 0, timedOut: false, aborted: false, sandbox: { mode: 'workspace-write', denied: false }, stdout: { text: request.command.startsWith('git -c core.quotepath=false log') ? 'abcdef1234567890\n【LIVE-1】【TASK】done\n\napp.js\n' : 'checks passed' }, stderr: { text: '' } }
36
+ } }
37
+ return undefined
38
+ } }
39
+ registerDevTask(ctx)
40
+ const exec = { agent: { session }, signal: abort.signal }
41
+ function load(name, success = true) {
42
+ const callId = `call-${events.length}`
43
+ events.push({ type: 'tool/call', data: { name: 'skill', callId, arguments: JSON.stringify({ name }) } })
44
+ events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: callId, isError: !success }] } } })
45
+ }
46
+ return { call: args => execute(JSON.parse(JSON.stringify({ task_id: 'LIVE-1', ...args })), exec), load, runs, policy, exec, records, cwd,
47
+ state: () => JSON.parse(records.get(join(cwd, '.dsh/task-LIVE-1.json'))) }
48
+ }
49
+
50
+ test('审查通过后更新状态并激活下一项,保留已完成项的审计', async () => {
51
+ const f = fixture()
52
+ await f.call({ operation: 'items', items: [{ id: 'A', title: 'first', status: 'doing' }, { id: 'B', title: 'second' }] })
53
+ await f.call({ operation: 'dispatch', item_id: 'A', description: 'implemented by worker' })
54
+ await f.call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
55
+ await f.call({ operation: 'items', items: [{ id: 'A', title: 'first', status: 'done' }, { id: 'B', title: 'second', status: 'doing' }] })
56
+ assert.equal(f.state().items[0].review.spec.outcome, 'pass')
57
+ assert.equal(f.state().items[0].dispatch.description, 'implemented by worker')
58
+ await f.call({ operation: 'items', items: [{ id: 'A', title: 'changed', status: 'doing' }, { id: 'B', title: 'second', status: 'todo' }] })
59
+ assert.equal(f.state().items[0].review, undefined)
60
+ await assert.rejects(f.call({ operation: 'items', items: [{ id: 'A', title: 'x' }, { id: 'A', title: 'y' }] }), /unique/)
61
+ })
62
+
63
+ test('追加修复项可按 id 保留旧标题,拒绝已完成项改名后静默丢审核', async () => {
64
+ const f = fixture()
65
+ await f.call({ operation: 'items', items: [{ id: 'A', title: 'reviewed models', status: 'doing' }] })
66
+ await f.call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
67
+ await f.call({ operation: 'items', items: [{ id: 'A', status: 'done' }, { id: 'B', title: 'fix race', status: 'doing' }] })
68
+ assert.equal(f.state().items[0].title, 'reviewed models')
69
+ assert.equal(f.state().items[0].review.quality.outcome, 'pass')
70
+ await assert.rejects(f.call({ operation: 'items', items: [{ id: 'A', title: 'rewritten summary', status: 'done' }, { id: 'B', status: 'doing' }] }), /omit title|reopen/)
71
+ assert.equal(f.state().items[0].review.quality.outcome, 'pass')
72
+ assert.equal(f.state().items.length, 2)
73
+ await assert.rejects(f.call({ operation: 'items', items: [{ id: 'NEW' }] }), /title/)
74
+ })
75
+
76
+ test('派发立即反映进行中,重派不复用旧审核且保持单一进行项', async () => {
77
+ const f = fixture()
78
+ await f.call({ operation: 'items', items: [{ id: 'A', title: 'models' }, { id: 'B', title: 'api' }] })
79
+ await f.call({ operation: 'dispatch', item_id: 'A', description: 'implement models' })
80
+ assert.equal(f.state().items[0].status, 'doing')
81
+ await assert.rejects(f.call({ operation: 'dispatch', item_id: 'B' }), /current doing item/)
82
+ await f.call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
83
+ await f.call({ operation: 'dispatch', item_id: 'A', description: 'rework models' })
84
+ assert.equal(f.state().items[0].review, undefined)
85
+ })
86
+
87
+ test('无任务 id 的 status 发现当前工作区任务并支持分支过滤', async () => {
88
+ const f = fixture()
89
+ assert.equal(JSON.parse(await f.call({ operation: 'status', task_id: undefined })).tasks[0].id, 'LIVE-1')
90
+ assert.equal(JSON.parse(await f.call({ operation: 'status', task_id: undefined, branch: 'other' })).tasks.length, 0)
91
+ })
92
+
93
+ test('状态明确区分内置节点门禁与附加技能命令回执,避免重复验证', async () => {
94
+ const f = fixture('代码审核')
95
+ const status = JSON.parse(await f.call({ operation: 'status' }))
96
+ assert.deepEqual(status.skill_obligations.find(row => row.stage === '代码审核').command_receipts_required, [])
97
+ assert.deepEqual(status.skill_obligations.find(row => row.stage === '完成').command_receipts_required, ['software-testing'])
98
+ })
99
+
100
+ test('status 披露当前阶段记录字段和缺项,字段误用不写入且可按提示恢复', async () => {
101
+ const f = fixture('需求评审')
102
+ const before = JSON.stringify(f.state())
103
+ const status = JSON.parse(await f.call({ operation: 'status' }))
104
+ assert.deepEqual(status.artifact_requirements, [{ id: 'requirement', name: '需求说明', fields: ['scope', 'acceptance_criteria'], missing_fields: ['scope', 'acceptance_criteria'] }])
105
+ assert.equal(JSON.stringify(f.state()), before)
106
+ await assert.rejects(f.call({ operation: 'record', artifact: 'requirement', fields: { scope: 'goal', 待确认取舍: 'question' } }), /status.artifact_requirements/)
107
+ assert.equal(JSON.stringify(f.state()), before)
108
+ await f.call({ operation: 'record', artifact: 'requirement', fields: { scope: '目标及待确认取舍', acceptance_criteria: ' ' } })
109
+ assert.deepEqual(JSON.parse(await f.call({ operation: 'status' })).artifact_requirements[0].missing_fields, ['acceptance_criteria'])
110
+ await f.call({ operation: 'record', artifact: 'requirement', fields: { acceptance_criteria: '可执行验收' } })
111
+ assert.deepEqual(JSON.parse(await f.call({ operation: 'status' })).artifact_requirements[0].missing_fields, [])
112
+ })
113
+
114
+ test('记录字段来自冻结流程,精简流程不硬编码标准字段,无记录阶段返回空列表', async () => {
115
+ const config = resolveFlow('agile').config
116
+ const f = fixture('需求', { flow: { flow: 'agile', version: 1, config } })
117
+ assert.deepEqual(JSON.parse(await f.call({ operation: 'status' })).artifact_requirements, [{ id: 'requirement', name: '需求说明', fields: ['scope'], missing_fields: ['scope'] }])
118
+ await f.call({ operation: 'record', artifact: 'requirement', fields: { scope: '目标、验收与疑问' } })
119
+ assert.deepEqual(JSON.parse(await f.call({ operation: 'status' })).artifact_requirements[0].missing_fields, [])
120
+ assert.deepEqual(JSON.parse(await fixture('开发').call({ operation: 'status' })).artifact_requirements, [])
121
+ assert.deepEqual(JSON.parse(await fixture('设计').call({ operation: 'status' })).artifact_requirements[0].fields, ['approach', 'risks', 'impact'])
122
+ })
123
+
124
+ test('可选内置技能回执不会因范围变更升级成额外流转门禁', async () => {
125
+ const f = fixture('需求评审', { artifacts: { requirement: { scope: 'device picker', acceptance_criteria: 'contract and selection' } } })
126
+ f.load('requirement-analysis')
127
+ await f.call({ operation: 'skill_result', skill_name: 'requirement-analysis', command: 'check', evidence: ['optional check'] })
128
+ await f.call({ operation: 'scope', files: ['app.js', 'new-vo.java'] })
129
+ await f.call({ operation: 'advance', target_stage: '设计' })
130
+ assert.equal(f.state().stage, '设计')
131
+ })
132
+
133
+ test('验证使用会话策略与取消信号,并明确返回失败及沙箱信息', async () => {
134
+ const f = fixture('交付')
135
+ const result = JSON.parse(await f.call({ operation: 'verify', command: 'fail' }))
136
+ assert.equal(result.ok, false)
137
+ assert.equal(f.runs[0].sandboxPolicy, f.policy)
138
+ assert.equal(f.runs[0].signal, f.exec.signal)
139
+ assert.equal(result.verification.receipt.sandbox.mode, 'workspace-write')
140
+ })
141
+
142
+ test('只声明已测试、或加载失败,均不能冒充执行绑定技能', async () => {
143
+ const f = fixture('代码审核')
144
+ f.load('code-review'); f.load('code-commit'); f.load('software-testing', false)
145
+ await assert.rejects(f.call({ operation: 'skill_result', target_stage: '完成', skill_name: 'software-testing', command: 'check', evidence: ['tested'] }), /load skill/)
146
+ await assert.rejects(f.call({ operation: 'advance', target_stage: '完成' }), /software-testing/)
147
+ f.load('software-testing')
148
+ await assert.rejects(f.call({ operation: 'skill_result', target_stage: '完成', skill_name: 'software-testing', evidence: ['claimed pass'] }), /real validation command/)
149
+ })
150
+
151
+ test('绑定技能失败阻止完成;成功后仍须提交真实 Git 回执', async () => {
152
+ const f = fixture('代码审核', { review: { outcome: 'pass' }, artifacts: { review: { conclusion: 'pass', issues: 'none' } }, verification: { passed: true, evidence: ['checks'] } })
153
+ f.load('code-review'); f.load('code-commit'); f.load('software-testing')
154
+ await f.call({ operation: 'verify', command: 'check', evidence: ['real checks'] })
155
+ await f.call({ operation: 'skill_result', target_stage: '完成', skill_name: 'software-testing', command: 'fail', evidence: ['scenario failed'] })
156
+ await assert.rejects(f.call({ operation: 'advance', target_stage: '完成' }), /skill_result/)
157
+ await f.call({ operation: 'skill_result', target_stage: '完成', skill_name: 'software-testing', command: 'check', evidence: ['scenario passed'] })
158
+ await assert.rejects(f.call({ operation: 'advance', target_stage: '完成' }), /commit required/)
159
+ await f.call({ operation: 'commit', files: ['app.js'], message: '【LIVE-1】【TASK】done' })
160
+ await assert.rejects(f.call({ operation: 'commit', files: ['app.js'], message: '【LIVE-1】【TASK】done', hash: 'bbbbbbb' }), /does not match/)
161
+ await f.call({ operation: 'commit', files: ['app.js'], message: '【LIVE-1】【TASK】done', hash: 'abcdef1' })
162
+ await f.call({ operation: 'advance', target_stage: '完成' })
163
+ assert.equal(f.state().stage, '完成')
164
+ assert.equal(f.state().skill_results.完成['software-testing'].load_call_id.startsWith('call-'), true)
165
+ })
166
+
167
+ test('验证后修改代码会使旧回执失效,必须重新验证', async () => {
168
+ const f = fixture('交付')
169
+ f.load('code-verify')
170
+ await f.call({ operation: 'verify', command: 'check' })
171
+ f.records.set(join(f.cwd, 'app.js'), 'modified source')
172
+ await assert.rejects(f.call({ operation: 'advance', target_stage: '代码审核' }), /stale/)
173
+ await f.call({ operation: 'verify', command: 'check' })
174
+ await f.call({ operation: 'advance', target_stage: '代码审核' })
175
+ assert.equal(f.state().stage, '代码审核')
176
+ })
177
+
178
+ test('status 提交提示与真实门禁一致,分别披露过期验证和附加技能回执且不改台账', async () => {
179
+ const f = fixture('代码审核', { review: { outcome: 'pass' }, artifacts: { review: { conclusion: 'pass', issues: 'none' } } })
180
+ await f.call({ operation: 'verify', command: 'check' })
181
+ assert.equal(JSON.parse(await f.call({ operation: 'status' })).commit.allowed, false)
182
+ f.load('code-review'); f.load('code-commit'); f.load('software-testing')
183
+ await f.call({ operation: 'skill_result', target_stage: '完成', skill_name: 'software-testing', command: 'check', evidence: ['tested'] })
184
+ assert.equal(JSON.parse(await f.call({ operation: 'status' })).commit.allowed, true)
185
+ f.records.set(join(f.cwd, 'app.js'), 'changed after testing')
186
+ const before = JSON.stringify(f.state())
187
+ const stale = JSON.parse(await f.call({ operation: 'status' }))
188
+ assert.equal(stale.commit.allowed, false)
189
+ assert.match(stale.commit.reason, /stale/)
190
+ assert.equal(stale.evidence_blockers.length, 2)
191
+ assert.equal(JSON.stringify(f.state()), before)
192
+ await assert.rejects(f.call({ operation: 'commit', files: ['app.js'], message: '【LIVE-1】【TASK】done' }), /stale/)
193
+ await f.call({ operation: 'verify', command: 'check' })
194
+ const partial = JSON.parse(await f.call({ operation: 'status' }))
195
+ assert.equal(partial.commit.allowed, false)
196
+ assert.equal(partial.evidence_blockers.length, 1)
197
+ assert.match(partial.evidence_blockers[0], /software-testing/)
198
+ await f.call({ operation: 'skill_result', target_stage: '完成', skill_name: 'software-testing', command: 'check', evidence: ['retested'] })
199
+ const current = JSON.parse(await f.call({ operation: 'status' }))
200
+ assert.equal(current.commit.allowed, true)
201
+ assert.deepEqual(current.evidence_blockers, [])
202
+ })
203
+
204
+ test('同名技能与规则在项目和个人目录各自保留管理入口', async t => {
205
+ const root = mkdtempSync(join(tmpdir(), 'engine-catalog-'))
206
+ const original = process.env.DSH_HOME
207
+ t.after(() => { if (original === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = original; assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep)); rmSync(root, { recursive: true, force: true }) })
208
+ process.env.DSH_HOME = join(root, 'user')
209
+ const project = join(root, 'project')
210
+ for (const base of [join(project, '.dsh'), process.env.DSH_HOME]) {
211
+ mkdirSync(join(base, 'skills/software-testing'), { recursive: true })
212
+ mkdirSync(join(base, 'rules'), { recursive: true })
213
+ writeFileSync(join(base, 'skills/software-testing/SKILL.md'), '\uFEFF---\r\nname: software-testing\r\ndescription: tests\r\n---\r\nRun tests.\r\n')
214
+ writeFileSync(join(base, 'rules/test-rule.md'), 'Test rule\n')
215
+ }
216
+ const receiver = { authorizedPath: async path => path }
217
+ const skills = await Controller.prototype.listSkills.call(receiver, project)
218
+ const rules = await Controller.prototype.listRules.call(receiver, project)
219
+ assert.deepEqual(skills.skills.filter(x => x.name === 'software-testing').map(x => x.source), ['project', 'user'])
220
+ assert.deepEqual(rules.rules.filter(x => x.name === 'test-rule').map(x => x.source), ['project', 'user'])
221
+ const read = await Controller.prototype.readSkill.call(receiver, { path: project, level: 'project', name: 'software-testing' })
222
+ assert.equal(read.ok, true)
223
+ assert.match(read.content, /Run tests/)
224
+ })
225
+
226
+ test('安装包的七个内置技能实际注册且可在资源目录发现', async () => {
227
+ const registered = []
228
+ registerShippedSkills({ get: name => name === 'skills' ? { register: skill => { registered.push(skill); return () => {} } } : undefined })
229
+ const expected = ['code-commit', 'code-implement', 'code-review', 'code-verify', 'eng-delivery', 'requirement-analysis', 'solution-design']
230
+ assert.deepEqual(registered.map(skill => skill.name).sort(), expected)
231
+ assert.ok(registered.every(skill => skill.content.trim().length > 0))
232
+ const catalog = await Controller.prototype.listSkills.call({ authorizedPath: async path => path }, '')
233
+ assert.deepEqual(catalog.skills.filter(skill => skill.source === 'bundled').map(skill => skill.name).sort(), expected)
234
+ })
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 godv61
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.