@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
@@ -5,266 +5,254 @@
5
5
  * commit rule, artifact declarations or a review depth were resolved for the preview
6
6
  * and then dropped on save. A user could set them, see them applied, and lose them.
7
7
  */
8
- import test from 'node:test'
9
- import assert from 'node:assert/strict'
10
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
11
- import { tmpdir } from 'node:os'
12
- import { dirname, join, resolve, sep } from 'node:path'
13
- import { resolveFlow, adoptRecommendation, compactProjectConfig, materializeBundledReferences } from './lib/workflows.js'
14
- import Controller from './lib/controller.js'
15
- import { assertAdvance, validateWorkflow } from './lib/engine.js'
16
- import { TYPERT_REMOTE } from './src/client/remote.ts'
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { dirname, join, resolve, sep } from 'node:path'
13
+ import { resolveFlow, adoptRecommendation, compactProjectConfig, materializeBundledReferences } from './lib/workflows.js'
14
+ import Controller from './lib/controller.js'
15
+ import { assertAdvance, validateWorkflow } from './lib/engine.js'
16
+ import { TYPERT_REMOTE } from './src/client/remote.ts'
17
17
 
18
18
  /** The fields a project config can carry, so a round-trip is checked on all of them. */
19
- const FIELDS = ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth', 'commit_required']
20
-
21
- test('browser remote codec retains source-qualified bindings and all saved fields', () => {
22
- const write = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'write')
23
- const read = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'read')
24
- const adopted = compactProjectConfig(adoptRecommendation('minimal'))
25
- const request = { path: 'project', ...adopted, review_depth: 'single', commit_required: false,
26
- materialize_bundled: 'project' }
27
- assert.deepEqual(write.parameters[0].codec.schema.parse(request), request)
28
- const resolved = resolveFlow('minimal', request).config
29
- const view = { ok: true, source: 'project', flow: 'minimal', config: resolved, problems: [] }
30
- assert.deepEqual(read.result.schema.parse(view), view)
31
- const catalog = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'listSkills')
32
- const skill = { name: 'mine', description: 'test', source: 'project',
33
- ref: { source: 'project', name: 'mine' }, sourceLabel: '项目' }
34
- assert.deepEqual(catalog.result.schema.parse({ skills: [skill] }), { skills: [skill] })
35
- const saveUserProfile = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'writeUserSkillProfile')
36
- const profileRequest = { name: 'mine', profile: { rules: [{ source: 'user', name: 'my-rule' }], evidence: 'manual' } }
37
- assert.deepEqual(saveUserProfile.parameters[0].codec.schema.parse(profileRequest), profileRequest)
38
- })
39
-
40
- test('one user skill profile is shared across workspaces and cannot depend on a project rule', async () => {
41
- const root = mkdtempSync(join(tmpdir(), 'dsh-user-profile-'))
42
- assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
43
- const priorHome = process.env.DSH_HOME
44
- const userHome = join(root, 'user-home')
45
- process.env.DSH_HOME = userHome
46
- const skill = { source: 'user', name: 'my-skill' }
47
- const config = { flow: 'minimal', stage_bindings: {
48
- '开发': { skill_refs: [skill] }, '交付': { skill_refs: [skill] },
49
- } }
50
- const fs = {
51
- resolve: async (path, { cwd }) => join(cwd, path),
52
- readText: async path => { try { return readFileSync(path, 'utf8') } catch { return undefined } },
53
- writeText: async (path, body) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, body) },
54
- }
55
- const receiver = { authorizedPath: async path => path, fs: () => fs }
56
- try {
57
- const skillDir = join(userHome, 'skills', skill.name)
58
- mkdirSync(skillDir, { recursive: true })
59
- writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: my-skill\ndescription: test\n---\nbody\n')
60
- const profile = { rules: [{ source: 'user', name: 'my-rule' }], evidence: 'manual' }
61
- const saved = await Controller.prototype.writeUserSkillProfile.call(receiver, { name: skill.name, profile })
62
- assert.equal(saved.ok, true)
63
- for (const projectName of ['one', 'two']) {
64
- const project = join(root, projectName)
65
- mkdirSync(join(project, '.dsh'), { recursive: true })
66
- writeFileSync(join(project, '.dsh', 'eng.json'), JSON.stringify(config))
67
- const loaded = await Controller.prototype.read.call(receiver, project)
68
- assert.equal(loaded.ok, true)
69
- assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, profile.rules)
70
- }
71
- const updatedProfile = { rules: [{ source: 'user', name: 'other-rule' }], evidence: 'none' }
72
- const updated = await Controller.prototype.writeUserSkillProfile.call(receiver,
73
- { name: skill.name, profile: updatedProfile })
74
- assert.equal(updated.ok, true)
75
- for (const projectName of ['one', 'two']) {
76
- const loaded = await Controller.prototype.read.call(receiver, join(root, projectName))
77
- assert.equal(loaded.ok, true)
78
- assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, updatedProfile.rules)
79
- }
80
- const bad = await Controller.prototype.writeUserSkillProfile.call(receiver, { name: skill.name,
81
- profile: { rules: [{ source: 'project', name: 'local-rule' }] } })
82
- assert.equal(bad.ok, false)
83
- assert.deepEqual(JSON.parse(readFileSync(join(skillDir, 'profile.json'), 'utf8')), updatedProfile)
84
- } finally {
85
- if (priorHome === undefined) delete process.env.DSH_HOME
86
- else process.env.DSH_HOME = priorHome
87
- rmSync(root, { recursive: true, force: true })
88
- }
89
- })
90
-
91
- test('recommendation adoption copies each shared bundled rule once and rewrites every reference', () => {
92
- const { config, copies } = materializeBundledReferences(adoptRecommendation('standard'), 'project')
93
- assert.equal(copies.filter(copy => copy.kind === 'skill').length, 6)
94
- assert.equal(copies.filter(copy => copy.kind === 'rule').length, 3)
95
- const refs = Object.values(config.stage_bindings).flatMap(binding => binding.skill_refs)
96
- assert.ok(refs.every(ref => ref.source === 'project'))
97
- const rules = Object.values(config.skill_profiles).flatMap(profile => profile.rules)
98
- assert.ok(rules.every(ref => ref.source === 'project'))
99
- const shared = rules.filter(ref => ref.name === 'security-redlines-copy')
100
- assert.equal(shared.length, 3)
101
- assert.equal(copies.filter(copy => copy.kind === 'rule' && copy.name === 'security-redlines').length, 1)
102
- assert.deepEqual(validateWorkflow(resolveFlow('standard', config).config), [])
103
- })
104
-
105
- test('project migration leaves a global user skill profile and its bundled rules untouched', () => {
106
- const userSkill = { source: 'user', name: 'global-check' }
107
- const bundledRule = { source: 'bundled', name: 'security-redlines' }
108
- const project = { flow: 'minimal', stage_bindings: { '开发': { skill_refs: [userSkill] } },
109
- skill_profiles: { 'user:global-check': { rules: [bundledRule] } } }
110
- const { config, copies } = materializeBundledReferences(project, 'project')
111
- assert.deepEqual(config.skill_profiles['user:global-check'].rules, [bundledRule])
112
- assert.deepEqual(copies, [])
113
- })
114
-
115
- test('adoption saves editable project copies, preserves edited files on repeat, and leaves legacy bundled configs alone', async () => {
116
- const root = mkdtempSync(join(tmpdir(), 'dsh-adopt-'))
117
- assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
118
- const fs = {
119
- resolve: async (path, { cwd }) => join(cwd, path),
120
- readText: async path => { try { return readFileSync(path, 'utf8') } catch { return undefined } },
121
- writeText: async (path, text) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, text) },
122
- }
123
- const receiver = { authorizedPath: async path => path, fs: () => fs }
124
- try {
125
- const recommended = adoptRecommendation('standard')
126
- const first = await Controller.prototype.write.call(receiver,
127
- { path: root, ...recommended, materialize_bundled: 'project' })
128
- assert.equal(first.ok, true)
129
- assert.equal(first.adoption.created.length, 9)
130
- const file = join(root, '.dsh', 'eng.json')
131
- const saved = JSON.parse(readFileSync(file, 'utf8'))
132
- assert.equal(JSON.stringify(saved).includes('"bundled"'), false)
133
- const shared = join(root, '.dsh', 'rules', 'security-redlines-copy.md')
134
- assert.ok(readFileSync(shared, 'utf8').length > 0)
135
- writeFileSync(shared, 'user edited shared rule\n')
136
- const second = await Controller.prototype.write.call(receiver,
137
- { path: root, ...recommended, materialize_bundled: 'project' })
138
- assert.equal(second.ok, true)
139
- assert.equal(second.adoption.created.length, 0)
140
- assert.equal(second.adoption.reused.length, 9)
141
- assert.equal(readFileSync(shared, 'utf8'), 'user edited shared rule\n')
142
- const loaded = await Controller.prototype.read.call(receiver, root)
143
- assert.equal(loaded.ok, true)
144
- const sharedRefs = Object.values(loaded.config.skill_profiles).flatMap(profile => profile.rules)
145
- .filter(rule => rule.name === 'security-redlines-copy')
146
- assert.equal(sharedRefs.length, 3)
147
- writeFileSync(file, JSON.stringify(compactProjectConfig(recommended)))
148
- const legacy = await Controller.prototype.read.call(receiver, root)
149
- assert.equal(legacy.ok, true)
150
- assert.ok(JSON.stringify(legacy.config).includes('"bundled"'))
151
- } finally {
152
- rmSync(root, { recursive: true, force: true })
153
- }
154
- })
155
-
156
- test('round-trip: stages store references while skill rules have one owner', () => {
157
- const adopted = adoptRecommendation('standard')
158
- const compact = compactProjectConfig(adopted)
159
- assert.ok(compact.stage_bindings['开发'].skill_refs.length > 0)
160
- assert.equal(compact.stage_bindings['开发'].skills, undefined)
161
- assert.ok(compact.skill_profiles['bundled:code-implement'].rules.length > 0)
162
- const readBack = resolveFlow('standard', JSON.parse(JSON.stringify(compact)))
163
- assert.deepEqual(readBack.config.stage_bindings, resolveFlow('standard', adopted).config.stage_bindings)
164
- const custom = { flow: 'standard', stage_bindings: {
165
- '开发': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r1' }] }] },
166
- '交付': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r2' }] }] },
167
- } }
168
- const invalid = resolveFlow('standard', custom)
169
- assert.ok(invalid.config.configuration_errors.some(problem => problem.includes('different rules')))
170
- })
171
-
172
- test('remote write stores canonical skill profiles and read expands them', async () => {
173
- let stored
174
- const fs = {
175
- resolve: async path => path,
176
- readText: async () => stored,
177
- writeText: async (_target, text) => { stored = text },
178
- }
179
- const receiver = { authorizedPath: async path => path, fs: () => fs }
180
- const adopted = adoptRecommendation('standard')
181
- const written = await Controller.prototype.write.call(receiver, { path: 'project', ...adopted })
182
- assert.equal(written.ok, true)
183
- const onDisk = JSON.parse(stored)
184
- assert.ok(onDisk.stage_bindings['开发'].skill_refs.length > 0)
185
- assert.equal(onDisk.stage_bindings['开发'].skills, undefined)
186
- assert.ok(onDisk.skill_profiles['bundled:code-implement'].rules.length > 0)
187
- const loaded = await Controller.prototype.read.call(receiver, 'project')
188
- assert.equal(loaded.ok, true)
189
- assert.deepEqual(loaded.config.stage_bindings, written.config.stage_bindings)
190
- assert.deepEqual(loaded.config.skill_profiles, written.config.skill_profiles)
191
- })
192
-
193
- test('an explicit skill profile governs every stage even when inline bindings are stale', () => {
194
- const skill = { source: 'project', name: 'my-skill' }
195
- const rule = { source: 'project', name: 'my-rule' }
196
- const project = { flow: 'minimal',
197
- stage_bindings: {
198
- '开发': { skills: [{ skill, rules: [] }] },
199
- '交付': { skills: [{ skill, rules: [] }] },
200
- },
201
- skill_profiles: { 'project:my-skill': { rules: [rule] } },
202
- }
203
- const resolved = resolveFlow('minimal', project)
204
- assert.equal(resolved.ok, true)
205
- assert.deepEqual(validateWorkflow(resolved.config), [])
206
- assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
207
- assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
208
- const compact = compactProjectConfig(project)
209
- assert.deepEqual(compact.stage_bindings['开发'].skill_refs, [skill])
210
- assert.deepEqual(resolveFlow('minimal', compact).config.stage_bindings['交付'].skills[0].rules, [rule])
211
- })
212
-
213
- test('canonical references to one skill share its rule list without a false conflict', () => {
214
- const skill = { source: 'project', name: 'my-skill' }
215
- const rule = { source: 'project', name: 'my-rule' }
216
- const resolved = resolveFlow('minimal', { flow: 'minimal', stage_bindings: {
217
- '开发': { skill_refs: [skill] },
218
- '交付': { skill_refs: [skill] },
219
- }, skill_profiles: { 'project:my-skill': { rules: [rule] } } })
220
- assert.deepEqual(validateWorkflow(resolved.config), [])
221
- assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
222
- assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
223
- })
224
-
225
- test('skill editor save persists one changed profile and read expands it into every stage', async () => {
226
- let stored
227
- const fs = { resolve: async path => path, readText: async () => stored,
228
- writeText: async (_target, content) => { stored = content } }
229
- const receiver = { authorizedPath: async path => path, fs: () => fs }
230
- const skill = { source: 'project', name: 'my-skill' }
231
- const rule = { source: 'project', name: 'my-rule' }
232
- const initial = compactProjectConfig({ flow: 'minimal', stage_bindings: {
233
- '开发': { skills: [{ skill, rules: [] }] },
234
- '交付': { skills: [{ skill, rules: [] }] },
235
- } })
236
- const edited = { ...initial, skill_profiles: { ...initial.skill_profiles,
237
- 'project:my-skill': { rules: [rule], evidence: 'none' } } }
238
- const written = await Controller.prototype.write.call(receiver, { path: 'project', ...edited })
239
- assert.equal(written.ok, true)
240
- assert.deepEqual(JSON.parse(stored).stage_bindings['开发'].skill_refs, [skill])
241
- const loaded = await Controller.prototype.read.call(receiver, 'project')
242
- assert.equal(loaded.ok, true)
243
- assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, [rule])
244
- assert.deepEqual(loaded.config.stage_bindings['交付'].skills[0].rules, [rule])
245
- })
246
-
247
- test('adopting a recommendation retains the user-owned skill profiles', () => {
248
- const custom = { 'project:my-skill': { rules: [{ source: 'project', name: 'my-rule' }], evidence: 'manual' } }
249
- const adopted = adoptRecommendation('standard', { flow: 'standard', skill_profiles: custom })
250
- assert.deepEqual(adopted.skill_profiles, custom)
251
- })
252
-
253
- test('malformed canonical skill profiles fail validation without crashing resolution', () => {
254
- const malformed = resolveFlow('standard', { flow: 'standard',
255
- stage_bindings: { '开发': { skill_refs: [{ source: 'project', name: 'my-skill' }] } },
256
- skill_profiles: { 'project:my-skill': { rules: 'not-an-array' } },
257
- })
258
- assert.ok(validateWorkflow(malformed.config).some(problem => problem.includes('rules array')))
259
- })
260
-
261
- test('commit_required false also removes the checkpoint exit requirement', () => {
262
- const adopted = adoptRecommendation('standard')
263
- const config = resolveFlow('standard', { ...adopted, commit_required: false }).config
264
- const state = { stage: '代码审核', execution_version: 1, review: { outcome: 'pass' },
265
- artifacts: { review: { conclusion: 'pass', issues: 'none' } }, commits: [] }
266
- assert.equal(assertAdvance(state, '完成', config).ok, true)
267
- })
19
+ const FIELDS = ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth', 'commit_required']
20
+
21
+ test('browser remote codec retains source-qualified bindings and all saved fields', () => {
22
+ const write = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'write')
23
+ const read = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'read')
24
+ const adopted = compactProjectConfig(adoptRecommendation('minimal'))
25
+ const request = { path: 'project', ...adopted, review_depth: 'single', commit_required: false,
26
+ materialize_bundled: 'project' }
27
+ assert.deepEqual(write.parameters[0].codec.schema.parse(request), request)
28
+ const resolved = resolveFlow('minimal', request).config
29
+ const view = { ok: true, source: 'project', flow: 'minimal', config: resolved, problems: [] }
30
+ assert.deepEqual(read.result.schema.parse(view), view)
31
+ const catalog = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'listSkills')
32
+ const skill = { name: 'mine', description: 'test', source: 'project',
33
+ ref: { source: 'project', name: 'mine' }, sourceLabel: '项目' }
34
+ assert.deepEqual(catalog.result.schema.parse({ skills: [skill] }), { skills: [skill] })
35
+ const saveUserProfile = TYPERT_REMOTE.descriptors.find(descriptor => descriptor.method === 'writeUserSkillProfile')
36
+ const profileRequest = { name: 'mine', profile: { rules: [{ source: 'user', name: 'my-rule' }], evidence: 'manual' } }
37
+ assert.deepEqual(saveUserProfile.parameters[0].codec.schema.parse(profileRequest), profileRequest)
38
+ })
39
+
40
+ test('one user skill profile is shared across workspaces and cannot depend on a project rule', async () => {
41
+ const root = mkdtempSync(join(tmpdir(), 'dsh-user-profile-'))
42
+ assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
43
+ const priorHome = process.env.DSH_HOME
44
+ const userHome = join(root, 'user-home')
45
+ process.env.DSH_HOME = userHome
46
+ const skill = { source: 'user', name: 'my-skill' }
47
+ const config = { flow: 'minimal', stage_bindings: {
48
+ '开发': { skill_refs: [skill] }, '交付': { skill_refs: [skill] },
49
+ } }
50
+ const fs = {
51
+ resolve: async (path, { cwd }) => join(cwd, path),
52
+ readText: async path => { try { return readFileSync(path, 'utf8') } catch { return undefined } },
53
+ writeText: async (path, body) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, body) },
54
+ }
55
+ const receiver = { authorizedPath: async path => path, fs: () => fs }
56
+ try {
57
+ const skillDir = join(userHome, 'skills', skill.name)
58
+ mkdirSync(skillDir, { recursive: true })
59
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: my-skill\ndescription: test\n---\nbody\n')
60
+ const profile = { rules: [{ source: 'user', name: 'my-rule' }], evidence: 'manual' }
61
+ const saved = await Controller.prototype.writeUserSkillProfile.call(receiver, { name: skill.name, profile })
62
+ assert.equal(saved.ok, true)
63
+ for (const projectName of ['one', 'two']) {
64
+ const project = join(root, projectName)
65
+ mkdirSync(join(project, '.dsh'), { recursive: true })
66
+ writeFileSync(join(project, '.dsh', 'eng.json'), JSON.stringify(config))
67
+ const loaded = await Controller.prototype.read.call(receiver, project)
68
+ assert.equal(loaded.ok, true)
69
+ assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, profile.rules)
70
+ }
71
+ const updatedProfile = { rules: [{ source: 'user', name: 'other-rule' }], evidence: 'none' }
72
+ const updated = await Controller.prototype.writeUserSkillProfile.call(receiver,
73
+ { name: skill.name, profile: updatedProfile })
74
+ assert.equal(updated.ok, true)
75
+ for (const projectName of ['one', 'two']) {
76
+ const loaded = await Controller.prototype.read.call(receiver, join(root, projectName))
77
+ assert.equal(loaded.ok, true)
78
+ assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, updatedProfile.rules)
79
+ }
80
+ const bad = await Controller.prototype.writeUserSkillProfile.call(receiver, { name: skill.name,
81
+ profile: { rules: [{ source: 'project', name: 'local-rule' }] } })
82
+ assert.equal(bad.ok, false)
83
+ assert.deepEqual(JSON.parse(readFileSync(join(skillDir, 'profile.json'), 'utf8')), updatedProfile)
84
+ } finally {
85
+ if (priorHome === undefined) delete process.env.DSH_HOME
86
+ else process.env.DSH_HOME = priorHome
87
+ rmSync(root, { recursive: true, force: true })
88
+ }
89
+ })
90
+
91
+ test('recommendation adoption contains no bundled resources', () => {
92
+ const { config, copies } = materializeBundledReferences(adoptRecommendation('standard'), 'project')
93
+ assert.deepEqual(copies, [])
94
+ assert.deepEqual(config.stage_bindings, {})
95
+ assert.equal(JSON.stringify(config).includes('bundled:'), false)
96
+ assert.deepEqual(validateWorkflow(resolveFlow('standard', config).config), [])
97
+ })
98
+
99
+ test('project migration leaves a global user skill profile and its bundled rules untouched', () => {
100
+ const userSkill = { source: 'user', name: 'global-check' }
101
+ const bundledRule = { source: 'bundled', name: 'security-redlines' }
102
+ const project = { flow: 'minimal', stage_bindings: { '开发': { skill_refs: [userSkill] } },
103
+ skill_profiles: { 'user:global-check': { rules: [bundledRule] } } }
104
+ const { config, copies } = materializeBundledReferences(project, 'project')
105
+ assert.deepEqual(config.skill_profiles['user:global-check'].rules, [bundledRule])
106
+ assert.deepEqual(copies, [])
107
+ })
108
+
109
+ test('recommendation saves only editable project configuration, without resource copies', async () => {
110
+ const root = mkdtempSync(join(tmpdir(), 'dsh-adopt-'))
111
+ assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep))
112
+ const fs = {
113
+ resolve: async (path, { cwd }) => join(cwd, path),
114
+ readText: async path => { try { return readFileSync(path, 'utf8') } catch { return undefined } },
115
+ writeText: async (path, text) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, text) },
116
+ }
117
+ const receiver = { authorizedPath: async path => path, fs: () => fs }
118
+ try {
119
+ const recommended = adoptRecommendation('standard')
120
+ const first = await Controller.prototype.write.call(receiver,
121
+ { path: root, ...recommended, materialize_bundled: 'project' })
122
+ assert.equal(first.ok, true)
123
+ assert.equal(first.adoption.created.length, 0)
124
+ const file = join(root, '.dsh', 'eng.json')
125
+ const saved = JSON.parse(readFileSync(file, 'utf8'))
126
+ assert.equal(JSON.stringify(saved).includes('"bundled"'), false)
127
+ const second = await Controller.prototype.write.call(receiver,
128
+ { path: root, ...recommended, materialize_bundled: 'project' })
129
+ assert.equal(second.ok, true)
130
+ assert.equal(second.adoption.created.length, 0)
131
+ assert.equal(second.adoption.reused.length, 0)
132
+ const loaded = await Controller.prototype.read.call(receiver, root)
133
+ assert.equal(loaded.ok, true)
134
+ assert.deepEqual(loaded.config.stage_bindings, {})
135
+ } finally {
136
+ rmSync(root, { recursive: true, force: true })
137
+ }
138
+ })
139
+
140
+ test('round-trip: stages store references while skill rules have one owner', () => {
141
+ const adopted = { ...adoptRecommendation('standard'), stage_bindings: {
142
+ '开发': { skill_refs: [{ source: 'project', name: 'my-implementation' }] },
143
+ }, skill_profiles: { 'project:my-implementation': { rules: [{ source: 'project', name: 'my-rule' }] } } }
144
+ const compact = compactProjectConfig(adopted)
145
+ assert.ok(compact.stage_bindings['开发'].skill_refs.length > 0)
146
+ assert.equal(compact.stage_bindings['开发'].skills, undefined)
147
+ assert.ok(compact.skill_profiles['project:my-implementation'].rules.length > 0)
148
+ const readBack = resolveFlow('standard', JSON.parse(JSON.stringify(compact)))
149
+ assert.deepEqual(readBack.config.stage_bindings, resolveFlow('standard', adopted).config.stage_bindings)
150
+ const custom = { flow: 'standard', stage_bindings: {
151
+ '开发': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r1' }] }] },
152
+ '交付': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r2' }] }] },
153
+ } }
154
+ const invalid = resolveFlow('standard', custom)
155
+ assert.ok(invalid.config.configuration_errors.some(problem => problem.includes('different rules')))
156
+ })
157
+
158
+ test('remote write stores canonical skill profiles and read expands them', async () => {
159
+ let stored
160
+ const fs = {
161
+ resolve: async path => path,
162
+ readText: async () => stored,
163
+ writeText: async (_target, text) => { stored = text },
164
+ }
165
+ const receiver = { authorizedPath: async path => path, fs: () => fs }
166
+ const adopted = { ...adoptRecommendation('standard'), stage_bindings: {
167
+ '开发': { skill_refs: [{ source: 'project', name: 'my-implementation' }] },
168
+ }, skill_profiles: { 'project:my-implementation': { rules: [{ source: 'project', name: 'my-rule' }] } } }
169
+ const written = await Controller.prototype.write.call(receiver, { path: 'project', ...adopted })
170
+ assert.equal(written.ok, true)
171
+ const onDisk = JSON.parse(stored)
172
+ assert.ok(onDisk.stage_bindings['开发'].skill_refs.length > 0)
173
+ assert.equal(onDisk.stage_bindings['开发'].skills, undefined)
174
+ assert.ok(onDisk.skill_profiles['project:my-implementation'].rules.length > 0)
175
+ const loaded = await Controller.prototype.read.call(receiver, 'project')
176
+ assert.equal(loaded.ok, true)
177
+ assert.deepEqual(loaded.config.stage_bindings, written.config.stage_bindings)
178
+ assert.deepEqual(loaded.config.skill_profiles, written.config.skill_profiles)
179
+ })
180
+
181
+ test('an explicit skill profile governs every stage even when inline bindings are stale', () => {
182
+ const skill = { source: 'project', name: 'my-skill' }
183
+ const rule = { source: 'project', name: 'my-rule' }
184
+ const project = { flow: 'minimal',
185
+ stage_bindings: {
186
+ '开发': { skills: [{ skill, rules: [] }] },
187
+ '交付': { skills: [{ skill, rules: [] }] },
188
+ },
189
+ skill_profiles: { 'project:my-skill': { rules: [rule] } },
190
+ }
191
+ const resolved = resolveFlow('minimal', project)
192
+ assert.equal(resolved.ok, true)
193
+ assert.deepEqual(validateWorkflow(resolved.config), [])
194
+ assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
195
+ assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
196
+ const compact = compactProjectConfig(project)
197
+ assert.deepEqual(compact.stage_bindings['开发'].skill_refs, [skill])
198
+ assert.deepEqual(resolveFlow('minimal', compact).config.stage_bindings['交付'].skills[0].rules, [rule])
199
+ })
200
+
201
+ test('canonical references to one skill share its rule list without a false conflict', () => {
202
+ const skill = { source: 'project', name: 'my-skill' }
203
+ const rule = { source: 'project', name: 'my-rule' }
204
+ const resolved = resolveFlow('minimal', { flow: 'minimal', stage_bindings: {
205
+ '开发': { skill_refs: [skill] },
206
+ '交付': { skill_refs: [skill] },
207
+ }, skill_profiles: { 'project:my-skill': { rules: [rule] } } })
208
+ assert.deepEqual(validateWorkflow(resolved.config), [])
209
+ assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
210
+ assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
211
+ })
212
+
213
+ test('skill editor save persists one changed profile and read expands it into every stage', async () => {
214
+ let stored
215
+ const fs = { resolve: async path => path, readText: async () => stored,
216
+ writeText: async (_target, content) => { stored = content } }
217
+ const receiver = { authorizedPath: async path => path, fs: () => fs }
218
+ const skill = { source: 'project', name: 'my-skill' }
219
+ const rule = { source: 'project', name: 'my-rule' }
220
+ const initial = compactProjectConfig({ flow: 'minimal', stage_bindings: {
221
+ '开发': { skills: [{ skill, rules: [] }] },
222
+ '交付': { skills: [{ skill, rules: [] }] },
223
+ } })
224
+ const edited = { ...initial, skill_profiles: { ...initial.skill_profiles,
225
+ 'project:my-skill': { rules: [rule], evidence: 'none' } } }
226
+ const written = await Controller.prototype.write.call(receiver, { path: 'project', ...edited })
227
+ assert.equal(written.ok, true)
228
+ assert.deepEqual(JSON.parse(stored).stage_bindings['开发'].skill_refs, [skill])
229
+ const loaded = await Controller.prototype.read.call(receiver, 'project')
230
+ assert.equal(loaded.ok, true)
231
+ assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, [rule])
232
+ assert.deepEqual(loaded.config.stage_bindings['交付'].skills[0].rules, [rule])
233
+ })
234
+
235
+ test('adopting a recommendation retains the user-owned skill profiles', () => {
236
+ const custom = { 'project:my-skill': { rules: [{ source: 'project', name: 'my-rule' }], evidence: 'manual' } }
237
+ const adopted = adoptRecommendation('standard', { flow: 'standard', skill_profiles: custom })
238
+ assert.deepEqual(adopted.skill_profiles, custom)
239
+ })
240
+
241
+ test('malformed canonical skill profiles fail validation without crashing resolution', () => {
242
+ const malformed = resolveFlow('standard', { flow: 'standard',
243
+ stage_bindings: { '开发': { skill_refs: [{ source: 'project', name: 'my-skill' }] } },
244
+ skill_profiles: { 'project:my-skill': { rules: 'not-an-array' } },
245
+ })
246
+ assert.ok(validateWorkflow(malformed.config).some(problem => problem.includes('rules array')))
247
+ })
248
+
249
+ test('commit_required false also removes the checkpoint exit requirement', () => {
250
+ const adopted = adoptRecommendation('standard')
251
+ const config = resolveFlow('standard', { ...adopted, commit_required: false }).config
252
+ const state = { stage: '代码审核', execution_version: 1, review: { outcome: 'pass' },
253
+ artifacts: { review: { conclusion: 'pass', issues: 'none' } }, commits: [] }
254
+ assert.equal(assertAdvance(state, '完成', config).ok, true)
255
+ })
268
256
 
269
257
  test('round-trip: every configurable field survives being written and read back', () => {
270
258
  // The save path serialises the payload and the read path parses it back through
@@ -314,4 +302,4 @@ test('round-trip: the payload the panel sends contains every edited field', () =
314
302
  // And the whole payload must be accepted by the resolver the write path uses.
315
303
  const resolved = resolveFlow(payload.flow, payload)
316
304
  assert.equal(resolved.ok, true, 'the payload the panel sends must resolve without problems')
317
- })
305
+ })