@godv61/dsh-task-engine 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.acceptance.mjs +9 -4
  2. package/.assessment-batch1.mjs +14 -11
  3. package/.codex-project-test.mjs +80 -0
  4. package/.enforce-test.mjs +11 -12
  5. package/.evidence-test.mjs +8 -8
  6. package/.filter-test.mjs +3 -5
  7. package/.freeze-test.mjs +40 -31
  8. package/.p0-test.mjs +16 -19
  9. package/.preset-test.mjs +11 -1
  10. package/.revision-test.mjs +16 -16
  11. package/.roundtrip-test.mjs +247 -259
  12. package/.workflow-test.mjs +117 -119
  13. package/README.md +12 -10
  14. package/cordis.patch.yml +158 -9
  15. package/defaults/eng.json +1 -97
  16. package/docs/CHANGELOG.md +39 -26
  17. package/docs/configuration.md +21 -22
  18. package/docs/development.md +1 -1
  19. package/docs/faq.md +5 -5
  20. package/docs/manual.html +41 -59
  21. package/docs/resource-install.md +2 -0
  22. package/docs/roadmap.md +2 -2
  23. package/hooks/commit-msg +4 -33
  24. package/lib/client.js +94 -108
  25. package/lib/client.js.map +3 -3
  26. package/lib/controller.d.ts +4 -3
  27. package/lib/controller.js +12 -6
  28. package/lib/controller.js.map +1 -1
  29. package/lib/dev-task.js +33 -27
  30. package/lib/dev-task.js.map +1 -1
  31. package/lib/engine.d.ts +2 -3
  32. package/lib/engine.js +2 -2
  33. package/lib/engine.js.map +1 -1
  34. package/lib/shipped-skills.d.ts +2 -4
  35. package/lib/shipped-skills.js +2 -4
  36. package/lib/shipped-skills.js.map +1 -1
  37. package/lib/skill-audit.d.ts +4 -4
  38. package/lib/skill-audit.js +19 -14
  39. package/lib/skill-audit.js.map +1 -1
  40. package/lib/workflows.d.ts +3 -4
  41. package/lib/workflows.js +2 -41
  42. package/lib/workflows.js.map +1 -1
  43. package/package.json +13 -15
  44. package/preset/agent.cordis.yml +3 -3
  45. package/preset/persona.md +2 -2
  46. package/scripts/verify-package.mjs +2 -0
  47. package/skills/eng-delivery/SKILL.md +17 -17
  48. package/rules/coding-conventions.md +0 -7
  49. package/rules/commit-conventions.md +0 -6
  50. package/rules/security-redlines.md +0 -6
  51. package/skills/code-commit/SKILL.md +0 -13
  52. package/skills/code-implement/SKILL.md +0 -24
  53. package/skills/code-review/SKILL.md +0 -13
  54. package/skills/code-verify/SKILL.md +0 -20
  55. package/skills/requirement-analysis/SKILL.md +0 -16
  56. package/skills/solution-design/SKILL.md +0 -19
package/.acceptance.mjs CHANGED
@@ -44,7 +44,12 @@ console.log('')
44
44
  console.log('=== 3. 采用后完全可编辑、可删除 ===')
45
45
  for (const id of ['standard', 'agile', 'minimal']) {
46
46
  const adopted = adoptRecommendation(id)
47
- check(`${id} 采用后带推荐技能`, Object.keys(adopted.stage_bindings ?? {}).length > 0)
47
+ check(`${id} 采用后仍不绑定业务技能`, Object.keys(adopted.stage_bindings ?? {}).length === 0)
48
+ const retained = adoptRecommendation(id, { flow: id, stage_bindings: {
49
+ 开发: { skill_refs: [{ source: 'project', name: 'mine' }] },
50
+ } })
51
+ check(`${id} 采用推荐文本时保留用户技能`, retained.stage_bindings.开发.skill_refs[0].name === 'mine')
52
+ if (id !== 'minimal') check(`${id} 推荐提交文本实际写入`, retained.commit.message_pattern !== '')
48
53
  const replaced = resolveFlow(id, { ...adopted, stage_bindings: {} }).config
49
54
  check(`${id} 清空后为空(不补回)`, Object.keys(replaced.stage_bindings ?? {}).length === 0)
50
55
  const mine = resolveFlow(id, {
@@ -101,13 +106,13 @@ for (const id of ['standard', 'agile', 'minimal']) {
101
106
  }
102
107
 
103
108
  console.log('')
104
- console.log('=== 7. defaults/eng.json 是采用后的配置示例 ===')
109
+ console.log('=== 7. defaults/eng.json 是空绑定配置示例 ===')
105
110
  const doc = JSON.parse(readFileSync('defaults/eng.json', 'utf8'))
106
111
  const merged = resolveFlow(doc.flow, doc)
107
112
  check('defaults/eng.json 可解析', merged.ok)
108
113
  if (merged.ok) {
109
114
  check('defaults/eng.json 无验证问题', validateWorkflow(merged.config).length === 0)
110
- check('defaults/eng.json 含技能(是采用后的样子)', Object.keys(merged.config.stage_bindings ?? {}).length > 0)
115
+ check('defaults/eng.json 不含技能', Object.keys(merged.config.stage_bindings ?? {}).length === 0)
111
116
  }
112
117
 
113
118
  console.log('')
@@ -148,4 +153,4 @@ check('三者范围互不相同', new Set([r.join(), s.join(), d.join()]).size =
148
153
 
149
154
  console.log('')
150
155
  console.log(failures === 0 ? '✅ 全部验收项通过' : `❌ ${failures} 项未通过`)
151
- process.exit(failures === 0 ? 0 : 1)
156
+ process.exit(failures === 0 ? 0 : 1)
@@ -320,16 +320,18 @@ test('batch3: the same skill carries identical rules wherever it is bound', () =
320
320
  test('batch3: a rule shared by two skills is one resource, referenced twice', () => {
321
321
  // Reuse must not duplicate the rule body: two skills referencing one rule point
322
322
  // at the same source-qualified reference.
323
- const config = adoptedFlow('standard')
324
- const review = config.stage_bindings['代码审核'].skills
325
- const reviewRules = review.find(entry => entry.skill.name === 'code-review').rules.map(formatResourceRef)
326
- const implementRules = config.stage_bindings['开发'].skills
327
- .find(entry => entry.skill.name === 'code-implement').rules.map(formatResourceRef)
323
+ const sharedRule = { source: 'project', name: 'shared-rule' }
324
+ const config = resolveFlow('standard', { flow: 'standard', stage_bindings: {
325
+ '开发': { skills: [{ skill: { source: 'project', name: 'implement' }, rules: [sharedRule] }] },
326
+ '代码审核': { skills: [{ skill: { source: 'project', name: 'review' }, rules: [sharedRule] }] },
327
+ } }).config
328
+ const reviewRules = config.stage_bindings['代码审核'].skills[0].rules.map(formatResourceRef)
329
+ const implementRules = config.stage_bindings['开发'].skills[0].rules.map(formatResourceRef)
328
330
  const shared = reviewRules.filter(rule => implementRules.includes(rule))
329
331
  assert.ok(shared.length > 0,
330
332
  'coding-conventions and security-redlines apply to both implementing and reviewing code, so at least one rule must be shared by reference')
331
- assert.ok(shared.includes('bundled:security-redlines'),
332
- `security-redlines is the clear shared case; saw ${JSON.stringify(shared)}`)
333
+ assert.ok(shared.includes('project:shared-rule'),
334
+ `the shared project rule is one reference; saw ${JSON.stringify(shared)}`)
333
335
  })
334
336
 
335
337
  test('batch3: legacy stage-level rules stay in force and are named as unassigned', async () => {
@@ -337,16 +339,17 @@ test('batch3: legacy stage-level rules stay in force and are named as unassigned
337
339
  // invent an answer the config never had; dropping them would lose a constraint.
338
340
  const f = fixture('standard', '开发', {}, {})
339
341
  const state = f.state()
340
- state.flow.config.stage_bindings = { 开发: { skills: [], legacy_rules: ['security-redlines'] } }
342
+ state.flow.config.stage_bindings = { 开发: { skills: [], legacy_rules: ['shared-rule'] } }
343
+ f.records.set(join(f.cwd, '.dsh/rules/shared-rule.md'), '# shared legacy rule')
341
344
  f.records.set(join(f.cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state))
342
345
  const status = JSON.parse(await f.call({ operation: 'status' }))
343
346
  assert.ok(
344
- (status.unassigned_legacy_rules ?? []).includes('security-redlines'),
347
+ (status.unassigned_legacy_rules ?? []).includes('shared-rule'),
345
348
  `the legacy rule must be reported as unassigned; saw ${JSON.stringify(status.unassigned_legacy_rules)}`,
346
349
  )
347
350
  // Still enforced: it resolves and appears among the rules in force.
348
351
  assert.ok(
349
- (status.rules ?? []).some(rule => rule.name === 'security-redlines'),
352
+ (status.rules ?? []).some(rule => rule.name === 'shared-rule'),
350
353
  `a legacy rule must keep applying; saw ${JSON.stringify(status.rules)}`,
351
354
  )
352
355
  })
@@ -420,4 +423,4 @@ test('P2: an unknown review_depth is a configuration error, not a silent downgra
420
423
  const problems = validateWorkflow(config)
421
424
  assert.ok(problems.some(p => /review_depth/u.test(p)),
422
425
  `a typo must be reported; saw ${JSON.stringify(problems)}`)
423
- })
426
+ })
@@ -0,0 +1,80 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { dirname, join, resolve, sep } from 'node:path'
6
+ import Controller from './lib/controller.js'
7
+ import { registerDevTask } from './lib/dev-task.js'
8
+ import { loadedSkills, skillBlockers } from './lib/skill-audit.js'
9
+ import { resolveFlow } from './lib/workflows.js'
10
+ import { validateWorkflow } from './lib/engine.js'
11
+
12
+ function fixture(t) {
13
+ const root = mkdtempSync(join(tmpdir(), 'dsh-codex-project-'))
14
+ assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
15
+ t.after(() => rmSync(root, { recursive: true, force: true }))
16
+ return root
17
+ }
18
+ function put(path, body) {
19
+ mkdirSync(dirname(path), { recursive: true })
20
+ writeFileSync(path, body)
21
+ }
22
+ function diskFs() {
23
+ return {
24
+ resolve: async (path, options) => resolve(options?.cwd ?? '', path),
25
+ readText: async path => { try { return readFileSync(path, 'utf8') } catch { return undefined } },
26
+ lstat: async (path, options) => { try { return { version: String(statSync(resolve(options?.cwd ?? '', path)).mtimeMs) } } catch { return undefined } },
27
+ writeText: async (path, body) => put(path, body),
28
+ listDir: async path => readdirSync(path, { withFileTypes: true }).map(entry => ({ name: entry.name })),
29
+ }
30
+ }
31
+
32
+ test('Codex project Skills are discovered separately; new DSH Skills stay in .dsh', async t => {
33
+ const root = fixture(t)
34
+ const body = '---\nname: same\ndescription: Codex method\n---\nCodex body v1\n'
35
+ put(join(root, '.agents/skills/same/SKILL.md'), body)
36
+ put(join(root, '.dsh/skills/same/SKILL.md'), '---\nname: same\ndescription: DSH method\n---\nDSH body\n')
37
+ const receiver = { authorizedPath: async path => path }
38
+ const catalog = await Controller.prototype.listSkills.call(receiver, root)
39
+ assert.deepEqual(catalog.skills.filter(skill => skill.name === 'same').map(skill => skill.ref.source).sort(), ['codex-project', 'project'])
40
+ const read = await Controller.prototype.readSkill.call(receiver, { level: 'codex-project', name: 'same', path: root })
41
+ assert.equal(read.content, 'Codex body v1')
42
+ const created = await Controller.prototype.writeSkill.call(receiver, { level: 'project', name: 'new-one', description: 'New DSH method', whenToUse: '', content: 'body', path: root })
43
+ assert.equal(created.ok, true)
44
+ assert.equal(readFileSync(join(root, '.dsh/skills/new-one/SKILL.md'), 'utf8').includes('body'), true)
45
+ })
46
+
47
+ test('a bound Codex Skill loads its attached DSH Rule and reads edits next time', async t => {
48
+ const root = fixture(t)
49
+ const skillFile = join(root, '.agents/skills/sample/SKILL.md')
50
+ const ruleFile = join(root, '.dsh/rules/shared.md')
51
+ put(skillFile, '---\nname: sample\ndescription: Sample\n---\nSkill v1\n')
52
+ put(ruleFile, 'Rule v1')
53
+ const ref = { source: 'codex-project', name: 'sample' }
54
+ const rule = { source: 'project', name: 'shared' }
55
+ const project = { flow: 'minimal', stage_bindings: { '开发': { skill_refs: [ref] } }, skill_profiles: { 'codex-project:sample': { rules: [rule], evidence: 'none' } } }
56
+ put(join(root, '.dsh/eng.json'), JSON.stringify(project))
57
+ assert.deepEqual(validateWorkflow(resolveFlow('minimal', project).config), [])
58
+ const fs = diskFs(), tools = []
59
+ registerDevTask({ fs, tools: { register(tool) { tools.push(tool) } }, get() { return undefined } })
60
+ const execute = tools[0].execute
61
+ const events = []
62
+ const session = { id: 'test-session', header: { cwd: root }, snapshotEvents: () => events }
63
+ const run = (args, callId = 'call') => execute(args, { agent: { session }, callId, signal: undefined })
64
+ await run({ operation: 'create', task_id: 'T1', title: 'Test', branch: 'main' })
65
+ const first = await run({ operation: 'load_skill', task_id: 'T1', skill_name: 'codex-project:sample' }, 'load-1')
66
+ assert.match(first, /Skill v1/)
67
+ assert.match(first, /Rule v1/)
68
+ events.push({ type: 'tool/call', data: { name: 'dev_task', callId: 'load-1', arguments: JSON.stringify({ operation: 'load_skill', task_id: 'T1', skill_name: 'codex-project:sample' }) } })
69
+ events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: 'load-1', isError: false }] } } })
70
+ assert.equal(loadedSkills(session).get('T1#codex-project:sample'), 'load-1')
71
+ const state = JSON.parse(readFileSync(join(root, '.dsh/task-T1.json'), 'utf8'))
72
+ assert.deepEqual(skillBlockers(state, state.flow.config, session), [])
73
+ assert.match(skillBlockers({ ...state, id: 'T2' }, state.flow.config, session).join('; '), /load_skill/,
74
+ 'loading a Skill for one task must not satisfy another task with a different Rule profile')
75
+ put(skillFile, '---\nname: sample\ndescription: Sample\n---\nSkill v2\n')
76
+ put(ruleFile, 'Rule v2')
77
+ const second = await run({ operation: 'load_skill', task_id: 'T1', skill_name: 'codex-project:sample' }, 'load-2')
78
+ assert.match(second, /Skill v2/)
79
+ assert.match(second, /Rule v2/)
80
+ })
package/.enforce-test.mjs CHANGED
@@ -15,14 +15,13 @@ import { completionBlockers, terminalRequirements, resourceBlockers } from './li
15
15
  async function project() {
16
16
  const cwd = resolve('enforce-project')
17
17
  const adopted = adoptRecommendation('standard')
18
- const skills = adopted.stage_bindings['开发'].skills.map(entry => ({
19
- ...entry,
20
- rules: [...entry.rules, { source: 'project', name: 'mine' }],
21
- }))
18
+ const skills = [{ skill: { source: 'project', name: 'my-implementation' },
19
+ rules: [{ source: 'project', name: 'mine' }], evidence: 'none' }]
22
20
  const stage_bindings = { ...adopted.stage_bindings, '开发': { skills } }
23
21
  const records = new Map([
24
22
  [join(cwd, '.dsh/eng.json'), JSON.stringify({ ...adopted, stage_bindings })],
25
23
  [join(cwd, '.dsh/rules/mine.md'), 'RULE'],
24
+ [join(cwd, '.dsh/skills/my-implementation/SKILL.md'), '---\nname: my-implementation\n---\nImplement.'],
26
25
  [join(cwd, 'a.js'), 'source'],
27
26
  ])
28
27
  const events = []
@@ -86,13 +85,13 @@ test('enforce: a task WITHOUT a snapshot cannot advance while a bound rule is un
86
85
  )
87
86
  })
88
87
 
89
- test('enforce: a task WITH a snapshot blocks when its live source rule is deleted', async () => {
90
- const f = await project()
91
- await satistfyDevelopment(f)
92
- f.records.delete(f.rule())
93
- await assert.rejects(() => f.call({ operation: 'advance', target_stage: '交付' }), /resolves nowhere/u)
94
- assert.equal(f.current().stage, '开发')
95
- })
88
+ test('enforce: a task WITH a snapshot blocks when its live source rule is deleted', async () => {
89
+ const f = await project()
90
+ await satistfyDevelopment(f)
91
+ f.records.delete(f.rule())
92
+ await assert.rejects(() => f.call({ operation: 'advance', target_stage: '交付' }), /resolves nowhere/u)
93
+ assert.equal(f.current().stage, '开发')
94
+ })
96
95
 
97
96
  test('enforce: completion requires the review and verification the flow declared', async () => {
98
97
  // Arriving at the last stage used to be enough. The agile flow declares a review
@@ -132,4 +131,4 @@ test('enforce: the resource blocker names the stage, the reference and the fix',
132
131
  assert.match(blockers[0], /Restore the file|remove the binding/u,
133
132
  'a blocker must say what to do, not only what is wrong')
134
133
  assert.deepEqual(resourceBlockers([], '开发'), [], 'a resolvable stage has no blocker')
135
- })
134
+ })
@@ -24,7 +24,7 @@ function sessionWith(names) {
24
24
  /** A standard-flow config with one extra skill on 开发 carrying the given evidence. */
25
25
  function withEvidence(evidence) {
26
26
  const adopted = adoptRecommendation('standard')
27
- const skills = [...adopted.stage_bindings['开发'].skills, { skill: { source: 'project', name: 'doc-skill' }, rules: [], ...(evidence !== undefined ? { evidence } : {}) }]
27
+ const skills = [{ skill: { source: 'project', name: 'doc-skill' }, rules: [], ...(evidence !== undefined ? { evidence } : {}) }]
28
28
  return resolveFlow('standard', { ...adopted, stage_bindings: { ...adopted.stage_bindings, '开发': { skills } } }).config
29
29
  }
30
30
 
@@ -42,8 +42,8 @@ function state(extra = {}) {
42
42
  }
43
43
 
44
44
  test('evidence: needsSkillReceipt honours a declared non-command kind', () => {
45
- // Core skills keep their exemption; a declared kind overrides the default.
46
- assert.equal(needsSkillReceipt('code-implement'), false, 'a core skill owes no separate receipt')
45
+ // No name-based exemption remains; a declared kind overrides the default.
46
+ assert.equal(needsSkillReceipt('code-implement'), true, 'skill names do not exempt command evidence')
47
47
  assert.equal(needsSkillReceipt('doc-skill'), true, 'an undeclared extra skill defaults to a command receipt')
48
48
  assert.equal(needsSkillReceipt('doc-skill', 'command'), true)
49
49
  assert.equal(needsSkillReceipt('doc-skill', 'artifact'), false)
@@ -56,8 +56,8 @@ test('evidence: a document-producing skill is satisfied by a recorded artifact',
56
56
  // The blocker is the point: without an artifact it blocks, and with one it does
57
57
  // not — so the declared kind is genuinely enforced rather than merely accepted.
58
58
  //
59
- // Mounted on 设计, which the adopted setup declares an artifact for. Missing
60
- // declarations and incomplete fields must both remain blockers.
59
+ // Mounted on 设计, which the adopted setup declares an artifact for. Missing
60
+ // declarations and incomplete fields must both remain blockers.
61
61
  const adopted = adoptRecommendation('standard')
62
62
  const config = resolveFlow('standard', {
63
63
  ...adopted,
@@ -78,7 +78,7 @@ test('evidence: a document-producing skill is satisfied by a recorded artifact',
78
78
  // Record what the stage declares and the blocker clears.
79
79
  const declared = config.artifacts.find(artifact => artifact.stage === '设计')
80
80
  assert.ok(declared !== undefined, 'the standard flow declares an artifact at 设计')
81
- const cleared = skillBlockers(atDesign({ artifacts: { [declared.id]: Object.fromEntries(declared.fields.map(field => [field, 'checked'])) } }), config, session)
81
+ const cleared = skillBlockers(atDesign({ artifacts: { [declared.id]: Object.fromEntries(declared.fields.map(field => [field, 'checked'])) } }), config, session)
82
82
  assert.deepEqual(cleared.filter(b => b.includes('doc-skill')), [],
83
83
  'a recorded artifact satisfies artifact evidence')
84
84
  })
@@ -98,7 +98,7 @@ test('evidence: manual evidence needs an explicit statement, none needs nothing'
98
98
  const session = sessionWith(['code-implement', 'doc-skill'])
99
99
  assert.ok(skillBlockers(state(), manual, session).some(b => b.includes('manual evidence')),
100
100
  'manual evidence is absent until something is recorded')
101
- const recorded = state({ skill_results: { '开发': { 'doc-skill': { evidence: ['reviewed by the lead'], approved: true } } } })
101
+ const recorded = state({ skill_results: { '开发': { 'doc-skill': { evidence: ['reviewed by the lead'], approved: true } } } })
102
102
  assert.deepEqual(skillBlockers(recorded, manual, session).filter(b => b.includes('doc-skill')), [],
103
103
  'a recorded statement satisfies manual evidence')
104
104
 
@@ -115,4 +115,4 @@ test('evidence: an undeclared extra skill still owes a command receipt', () => {
115
115
  const blockers = skillBlockers(state(), config, session)
116
116
  assert.ok(blockers.some(b => b.includes('validation command')),
117
117
  `an undeclared skill must still owe a command receipt; got ${JSON.stringify(blockers)}`)
118
- })
118
+ })
package/.filter-test.mjs CHANGED
@@ -83,11 +83,9 @@ test('filter: only-selected combined with a query still keeps what is in force',
83
83
  assert.ok(!shown.some(e => e.name === 'code-implement'), 'an unbound non-matching skill is dropped')
84
84
  })
85
85
 
86
- test('filter: the shipped catalog is small enough that the default view shows it all', () => {
87
- // Sanity: the initial view of a fresh project is the shipped set, unfiltered.
86
+ test('filter: a fresh project has no prebound business skill', () => {
88
87
  const adopted = adoptRecommendation('standard')
89
88
  const resolved = resolveFlow('standard', adopted).config
90
89
  const binding = bindingsForStage('开发', resolved)
91
- assert.ok(binding !== undefined, 'the adopted config binds 开发')
92
- assert.ok((binding.skills ?? []).length > 0)
93
- })
90
+ assert.equal(binding, undefined)
91
+ })
package/.freeze-test.mjs CHANGED
@@ -15,11 +15,21 @@ import { hashText } from './lib/snapshot.js'
15
15
  /** Create one task through the real tool and return its frozen snapshot. */
16
16
  async function created(flow) {
17
17
  const cwd = resolve('freeze-project')
18
+ const stage = { standard: '需求评审', agile: '需求', minimal: '开发' }[flow]
19
+ const shared = { source: 'project', name: 'shared-rule' }
20
+ const adopted = adoptRecommendation(flow)
21
+ const project = { ...adopted, stage_bindings: { [stage]: { skills: [
22
+ { skill: { source: 'project', name: 'first-skill' }, rules: [shared], evidence: 'none' },
23
+ { skill: { source: 'project', name: 'second-skill' }, rules: [shared], evidence: 'none' },
24
+ ] } } }
18
25
  const records = new Map([
19
26
  // A project that adopted the shipped recommendation, which is what gives it
20
27
  // skills and rules to freeze. `{flow}` alone adopts nothing and therefore has
21
28
  // nothing to freeze — a legitimate state, but not the one under test.
22
- [join(cwd, '.dsh/eng.json'), JSON.stringify(adoptRecommendation(flow))],
29
+ [join(cwd, '.dsh/eng.json'), JSON.stringify(project)],
30
+ [join(cwd, '.dsh/skills/first-skill/SKILL.md'), '# First skill'],
31
+ [join(cwd, '.dsh/skills/second-skill/SKILL.md'), '# Second skill'],
32
+ [join(cwd, '.dsh/rules/shared-rule.md'), '# Shared rule'],
23
33
  [join(cwd, 'a.js'), 'source'],
24
34
  ])
25
35
  const events = []
@@ -75,13 +85,13 @@ test('freeze: a frozen body carries its content and a hash of that content', asy
75
85
  })
76
86
 
77
87
  test('freeze: shared resources are stored once, not duplicated per reference', async () => {
78
- // security-redlines is referenced by two skills; the body must not be copied twice.
88
+ // One project rule is referenced by two skills; the body is stored once.
79
89
  const state = await created('standard')
80
90
  const resources = state.flow.resources ?? []
81
91
  const hashes = resources.map(r => r.hash)
82
92
  assert.equal(new Set(hashes).size, hashes.length,
83
93
  'identical bodies must not be stored more than once')
84
- assert.ok(resources.some(r => r.ref.name === 'security-redlines'),
94
+ assert.ok(resources.some(r => r.ref.name === 'shared-rule'),
85
95
  'the shared rule must be present')
86
96
  })
87
97
 
@@ -103,14 +113,13 @@ test('freeze: every preset freezes a complete, non-empty set', async () => {
103
113
  async function atDevelopmentWithProjectRule() {
104
114
  const cwd = resolve('freeze-live')
105
115
  const adopted = adoptRecommendation('standard')
106
- const skills = adopted.stage_bindings['开发'].skills.map(entry => ({
107
- ...entry,
108
- rules: [...entry.rules, { source: 'project', name: 'mine' }],
109
- }))
116
+ const skills = [{ skill: { source: 'project', name: 'my-implementation' },
117
+ rules: [{ source: 'project', name: 'mine' }], evidence: 'none' }]
110
118
  const project = { ...adopted, stage_bindings: { ...adopted.stage_bindings, '开发': { skills } } }
111
119
  const records = new Map([
112
120
  [join(cwd, '.dsh/eng.json'), JSON.stringify(project)],
113
121
  [join(cwd, '.dsh/rules/mine.md'), 'ORIGINAL RULE'],
122
+ [join(cwd, '.dsh/skills/my-implementation/SKILL.md'), '# Implementation skill'],
114
123
  [join(cwd, 'a.js'), 'source'],
115
124
  ])
116
125
  let execute
@@ -150,29 +159,29 @@ async function atDevelopmentWithProjectRule() {
150
159
  }
151
160
  }
152
161
 
153
- test('live: a running task reads the latest rule body at the same reference', async () => {
154
- const f = await atDevelopmentWithProjectRule()
155
- const before = await f.disclosed()
156
- assert.equal(before.mine?.content, 'ORIGINAL RULE')
157
-
158
- f.records.set(f.rule(), 'CHANGED RULE')
159
- const after = await f.disclosed()
160
- assert.equal(after.mine?.content, 'CHANGED RULE',
161
- 'the next interaction must disclose the edited body')
162
- assert.ok((after.status.stale_source_rules ?? []).some(r => r.includes('edited')),
163
- 'the difference from the creation-time audit copy must be visible')
164
- assert.match(after.status.resource_update_notice, /current source body is in force/u)
165
- })
166
-
167
- test('live: deleting a source rule makes the binding unresolved', async () => {
168
- const f = await atDevelopmentWithProjectRule()
169
- f.records.delete(f.rule())
170
- const { status, mine } = await f.disclosed()
171
- assert.equal(mine, undefined)
172
- assert.ok(status.missing_rules.includes('project:mine'))
173
- assert.ok((status.stale_source_rules ?? []).some(r => r.includes('deleted')),
174
- 'the deletion is also reported against the creation-time audit copy')
175
- })
162
+ test('live: a running task reads the latest rule body at the same reference', async () => {
163
+ const f = await atDevelopmentWithProjectRule()
164
+ const before = await f.disclosed()
165
+ assert.equal(before.mine?.content, 'ORIGINAL RULE')
166
+
167
+ f.records.set(f.rule(), 'CHANGED RULE')
168
+ const after = await f.disclosed()
169
+ assert.equal(after.mine?.content, 'CHANGED RULE',
170
+ 'the next interaction must disclose the edited body')
171
+ assert.ok((after.status.stale_source_rules ?? []).some(r => r.includes('edited')),
172
+ 'the difference from the creation-time audit copy must be visible')
173
+ assert.match(after.status.resource_update_notice, /current source body is in force/u)
174
+ })
175
+
176
+ test('live: deleting a source rule makes the binding unresolved', async () => {
177
+ const f = await atDevelopmentWithProjectRule()
178
+ f.records.delete(f.rule())
179
+ const { status, mine } = await f.disclosed()
180
+ assert.equal(mine, undefined)
181
+ assert.ok(status.missing_rules.includes('project:mine'))
182
+ assert.ok((status.stale_source_rules ?? []).some(r => r.includes('deleted')),
183
+ 'the deletion is also reported against the creation-time audit copy')
184
+ })
176
185
 
177
186
  test('freeze: an unfrozen task falls back to live resolution and reports a missing file', async () => {
178
187
  // Tasks created before freezing have no snapshot. They must keep working, and a
@@ -191,4 +200,4 @@ test('freeze: an unfrozen task falls back to live resolution and reports a missi
191
200
  assert.equal(mine, undefined, 'with no snapshot and no file, the rule cannot resolve')
192
201
  assert.ok((status.missing_rules ?? []).includes('project:mine'),
193
202
  'it is reported as missing so the gate can block on it')
194
- })
203
+ })
package/.p0-test.mjs CHANGED
@@ -152,20 +152,20 @@ assert(emptied.ok && (emptied.config.stage_bindings['开发'].skills ?? []).leng
152
152
  'a project that binds nothing to a stage keeps it empty; nothing is merged back in')
153
153
 
154
154
  const adopted = adoptedFlow('standard')
155
- assert(adopted.stage_bindings['开发'].skills.some(entry => entry.skill.name === 'code-implement'),
156
- 'adopting the recommendation is what supplies the shipped skills')
155
+ assert(Object.keys(adopted.stage_bindings ?? {}).length === 0,
156
+ 'adopting the recommendation does not supply skills')
157
157
 
158
- // And a user who gives a shipped skill their own rule list gets that list — the
158
+ // And a user who gives a project skill their own rule list gets that list — the
159
159
  // defect this replaces was that the entry was skipped as a duplicate reference.
160
160
  const ownRules = resolveFlow('standard', {
161
161
  flow: 'standard',
162
162
  stage_bindings: {
163
- '开发': { skills: [{ skill: { source: 'bundled', name: 'code-implement' }, rules: [{ source: 'project', name: 'only-mine' }] }] },
163
+ '开发': { skills: [{ skill: { source: 'project', name: 'my-implementation' }, rules: [{ source: 'project', name: 'only-mine' }] }] },
164
164
  },
165
165
  }).config
166
- const implementBinding = ownRules.stage_bindings['开发'].skills.find(entry => entry.skill.name === 'code-implement')
166
+ const implementBinding = ownRules.stage_bindings['开发'].skills.find(entry => entry.skill.name === 'my-implementation')
167
167
  assert(implementBinding.rules.length === 1 && implementBinding.rules[0].name === 'only-mine',
168
- 'a project rule list for a shipped skill is used as given, not discarded as a duplicate')
168
+ 'a project rule list for a project skill is used as given, not discarded as a duplicate')
169
169
 
170
170
  // ── 4. newTask freezes the snapshot ────────────────────────────────────────
171
171
  const snapshot = { flow: 'standard', version: 1, config: adoptedFlow('standard') }
@@ -267,8 +267,6 @@ await assertThrows(
267
267
  // is no chain to walk and no shadowing to defend against: the same name in two
268
268
  // layers is two distinct resources, and the binding says which one it means.
269
269
  {
270
- const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
271
- assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
272
270
  const fs = makeFs({
273
271
  // A project file with the same NAME but a different body. Under the old
274
272
  // bare-name lookup this would have replaced the bundled rule silently.
@@ -276,12 +274,13 @@ await assertThrows(
276
274
  flow: 'standard',
277
275
  stage_bindings: {
278
276
  '需求评审': { skills: [{
279
- skill: { source: 'bundled', name: 'requirement-analysis' },
277
+ skill: { source: 'project', name: 'requirement-analysis' },
280
278
  rules: [{ source: 'project', name: 'security-redlines' }],
281
279
  }] },
282
280
  },
283
281
  }),
284
282
  '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
283
+ '.dsh/skills/requirement-analysis/SKILL.md': '# Project requirement analysis',
285
284
  })
286
285
  const exe = await registered(fs)
287
286
  await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
@@ -292,26 +291,24 @@ await assertThrows(
292
291
  assert(sec.content.includes('前端校验即可'), 'the PROJECT body is what gets disclosed, because the reference names the project layer')
293
292
  }
294
293
 
295
- // ── 11b. a bundled reference keeps resolving the bundled body ───────────────
294
+ // ── 11b. a deleted bundled rule is reported as missing ───────────────────────
296
295
  {
297
296
  const fs = makeFs({
298
297
  '.dsh/eng.json': JSON.stringify({
299
298
  flow: 'standard',
300
299
  stage_bindings: {
301
300
  '需求评审': { skills: [{
302
- skill: { source: 'bundled', name: 'requirement-analysis' },
301
+ skill: { source: 'project', name: 'requirement-analysis' },
303
302
  rules: [{ source: 'bundled', name: 'security-redlines' }],
304
303
  }] },
305
304
  },
306
305
  }),
307
306
  '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
307
+ '.dsh/skills/requirement-analysis/SKILL.md': '# Project requirement analysis',
308
308
  })
309
309
  const exe = await registered(fs)
310
- await exe({ operation: 'create', task_id: 'T10', title: 'x', branch: 'main' }, EXEC)
311
- const status = JSON.parse(await exe({ operation: 'status', task_id: 'T10' }, EXEC))
312
- const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
313
- assert(sec !== undefined && sec.source === 'bundled', 'a bundled reference resolves to the bundled layer')
314
- assert(sec.content.includes('服务端'), 'the bundled body is used, untouched by the same-named project file')
310
+ await assertThrows(() => exe({ operation: 'create', task_id: 'T10', title: 'x', branch: 'main' }, EXEC),
311
+ 'unreadable', 'a reference to a removed bundled rule fails closed')
315
312
  }
316
313
 
317
314
  // ── 12. session cwd drives every relative path (init regression) ──────────
@@ -602,10 +599,10 @@ function memProbe(files) {
602
599
  const exe = await registered(fs)
603
600
  await exe({ operation: 'create', task_id: 'FP-1', title: 'x', branch: 'main' }, EXEC)
604
601
  const status = JSON.parse(await exe({ operation: 'status', task_id: 'FP-1' }, EXEC))
605
- assert(status.bindings_drift === undefined, 'fresh task shows no bindings drift')
602
+ assert(status.bindings_drift === undefined, 'fresh task has no bundled-rule drift marker')
606
603
  const record = JSON.parse(fs._files.get('.dsh/task-FP-1.json'))
607
- assert(typeof record.bindings_fingerprint === 'string' && record.bindings_fingerprint.length === 64,
608
- 'create records the bundled-rules fingerprint')
604
+ assert(record.bindings_fingerprint === undefined,
605
+ 'create does not record a fingerprint for removed bundled rules')
609
606
  }
610
607
 
611
608
  // ── 26. 0.22-C: multi-language verify command chain + parallel tasks ────────
package/.preset-test.mjs CHANGED
@@ -158,4 +158,14 @@ test('the rewritten persona row keeps valid indentation for both shapes', () =>
158
158
  const legacy = applyPersona(PERSONA_FIXTURE_LEGACY, 'ENGINEER').text
159
159
  assert.match(legacy, /- id: persona\n {2}name: '@deepseek-ai\/dsh-persona'\n {2}config:\n {4}text: [|>]-\n {6}ENGINEER\n/u,
160
160
  'the legacy row is indented correctly')
161
- })
161
+ })
162
+
163
+ test('DSH 0.2 bundle declares the engineering preset', () => {
164
+ const patch = readFileSync(resolve('cordis.patch.yml'), 'utf8')
165
+ assert.match(patch, /id: preset-eng/u)
166
+ assert.match(patch, /name: '@deepseek-ai\/dsh-agent-preset'/u)
167
+ assert.match(patch, /id: eng/u)
168
+ assert.match(patch, /engineering-delivery coding agent/u)
169
+ assert.match(patch, /id: task-engine-agent/u)
170
+ assert.match(patch, /name: '@godv61\/dsh-task-engine\/agent'/u)
171
+ })
@@ -92,7 +92,7 @@ test('rework: fixing a defect keeps the agreed requirement and solution', () =>
92
92
  assert.deepEqual(state.commits, [], 'the commit described a superseded tree')
93
93
  })
94
94
 
95
- test('rework: clearing evidence returns an item to unfinished, not to done-without-audit', () => {
95
+ test('rework: clearing evidence returns an item to unfinished, not to done-without-audit', () => {
96
96
  // A cleared review means the item is no longer proven done. Leaving it `done`
97
97
  // would let todos_done pass on work whose audit had just been discarded.
98
98
  const state = loadedTask()
@@ -111,18 +111,18 @@ test('rework: history records what was invalidated, for audit', () => {
111
111
  assert.equal(state.revisions[0].from, '代码审核')
112
112
  assert.equal(state.revisions[0].to, '设计')
113
113
  assert.deepEqual(state.revisions[0].invalidated, invalidatedBy('solution'))
114
- })
115
-
116
- test('rework: skill results from revised stages are invalidated', () => {
117
- const state = loadedTask()
118
- state.flow = { flow: 'standard', version: 3, config: adoptedFlow('standard') }
119
- state.skill_results = {
120
- '需求评审': { analysis: { evidence: ['still agreed'] } },
121
- '开发': { implementation: { evidence: ['old implementation'] } },
122
- '代码审核': { audit: { evidence: ['old audit'] } },
123
- }
124
- applyRevision(state, rework('defect', '开发'))
125
- assert.ok(state.skill_results['需求评审'])
126
- assert.equal(state.skill_results['开发'], undefined)
127
- assert.equal(state.skill_results['代码审核'], undefined)
128
- })
114
+ })
115
+
116
+ test('rework: skill results from revised stages are invalidated', () => {
117
+ const state = loadedTask()
118
+ state.flow = { flow: 'standard', version: 3, config: adoptedFlow('standard') }
119
+ state.skill_results = {
120
+ '需求评审': { analysis: { evidence: ['still agreed'] } },
121
+ '开发': { implementation: { evidence: ['old implementation'] } },
122
+ '代码审核': { audit: { evidence: ['old audit'] } },
123
+ }
124
+ applyRevision(state, rework('defect', '开发'))
125
+ assert.ok(state.skill_results['需求评审'])
126
+ assert.equal(state.skill_results['开发'], undefined)
127
+ assert.equal(state.skill_results['代码审核'], undefined)
128
+ })