@godv61/dsh-task-engine 0.26.1 → 0.27.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.
package/.enforce-test.mjs CHANGED
@@ -86,17 +86,13 @@ test('enforce: a task WITHOUT a snapshot cannot advance while a bound rule is un
86
86
  )
87
87
  })
88
88
 
89
- test('enforce: a task WITH a snapshot still advances after its source rule is deleted', async () => {
90
- // The other side of the same rule: the frozen copy IS the constraint, so a
91
- // deleted source is drift to report, not a reason to block. Without this the
92
- // freeze would be pointless — the task would be blocked by a file it no longer
93
- // needs.
94
- const f = await project()
95
- await satistfyDevelopment(f)
96
- f.records.delete(f.rule())
97
- await f.call({ operation: 'advance', target_stage: '交付' })
98
- assert.equal(f.current().stage, '交付', 'the frozen task advances on its own copy')
99
- })
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
+ })
100
96
 
101
97
  test('enforce: completion requires the review and verification the flow declared', async () => {
102
98
  // Arriving at the last stage used to be enough. The agile flow declares a review
@@ -136,4 +132,4 @@ test('enforce: the resource blocker names the stage, the reference and the fix',
136
132
  assert.match(blockers[0], /Restore the file|remove the binding/u,
137
133
  'a blocker must say what to do, not only what is wrong')
138
134
  assert.deepEqual(resourceBlockers([], '开发'), [], 'a resolvable stage has no blocker')
139
- })
135
+ })
@@ -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/.freeze-test.mjs CHANGED
@@ -150,34 +150,29 @@ async function atDevelopmentWithProjectRule() {
150
150
  }
151
151
  }
152
152
 
153
- test('freeze: a running task follows the FROZEN body after its source is edited', async () => {
154
- // Freezing that only archives the content is not freezing: status and the stage
155
- // disclosure used to read the live files, so editing a rule silently changed what
156
- // a running task was told to follow — the instability the snapshot exists to stop.
157
- const f = await atDevelopmentWithProjectRule()
158
- const before = await f.disclosed()
159
- assert.equal(before.mine?.content, 'ORIGINAL RULE', 'the frozen body is what gets disclosed')
160
-
161
- f.records.set(f.rule(), 'CHANGED RULE')
162
- const after = await f.disclosed()
163
- assert.equal(after.mine?.content, 'ORIGINAL RULE',
164
- 'an edit to the source must not change what the running task is told to follow')
165
- assert.ok((after.status.stale_source_rules ?? []).some(r => r.includes('edited')),
166
- 'the drift is reported so it is visible rather than silently authoritative')
167
- })
168
-
169
- test('freeze: a running task survives its source rule being deleted', async () => {
170
- // The frozen copy exists precisely so a deleted file is not a missing
171
- // constraint. Reporting it as missing would be wrong: the task has the body.
172
- const f = await atDevelopmentWithProjectRule()
173
- f.records.delete(f.rule())
174
- const { status, mine } = await f.disclosed()
175
- assert.equal(mine?.content, 'ORIGINAL RULE', 'the frozen body is still in force')
176
- assert.deepEqual(status.missing_rules, [],
177
- 'a frozen rule is not missing; the task already holds its content')
178
- assert.ok((status.stale_source_rules ?? []).some(r => r.includes('deleted')),
179
- 'the deletion is reported as drift')
180
- })
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
+ })
181
176
 
182
177
  test('freeze: an unfrozen task falls back to live resolution and reports a missing file', async () => {
183
178
  // Tasks created before freezing have no snapshot. They must keep working, and a
@@ -196,4 +191,4 @@ test('freeze: an unfrozen task falls back to live resolution and reports a missi
196
191
  assert.equal(mine, undefined, 'with no snapshot and no file, the rule cannot resolve')
197
192
  assert.ok((status.missing_rules ?? []).includes('project:mine'),
198
193
  'it is reported as missing so the gate can block on it')
199
- })
194
+ })
@@ -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
+ })
@@ -5,127 +5,266 @@
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 { resolveFlow, adoptRecommendation, compactProjectConfig } from './lib/workflows.js'
11
- import Controller from './lib/controller.js'
12
- import { assertAdvance, validateWorkflow } from './lib/engine.js'
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'
13
17
 
14
18
  /** The fields a project config can carry, so a round-trip is checked on all of them. */
15
- const FIELDS = ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth', 'commit_required']
16
-
17
- test('round-trip: stages store references while skill rules have one owner', () => {
18
- const adopted = adoptRecommendation('standard')
19
- const compact = compactProjectConfig(adopted)
20
- assert.ok(compact.stage_bindings['开发'].skill_refs.length > 0)
21
- assert.equal(compact.stage_bindings['开发'].skills, undefined)
22
- assert.ok(compact.skill_profiles['bundled:code-implement'].rules.length > 0)
23
- const readBack = resolveFlow('standard', JSON.parse(JSON.stringify(compact)))
24
- assert.deepEqual(readBack.config.stage_bindings, resolveFlow('standard', adopted).config.stage_bindings)
25
- const custom = { flow: 'standard', stage_bindings: {
26
- '开发': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r1' }] }] },
27
- '交付': { skills: [{ skill: { source: 'project', name: 'same' }, rules: [{ source: 'project', name: 'r2' }] }] },
28
- } }
29
- const invalid = resolveFlow('standard', custom)
30
- assert.ok(invalid.config.configuration_errors.some(problem => problem.includes('different rules')))
31
- })
32
-
33
- test('remote write stores canonical skill profiles and read expands them', async () => {
34
- let stored
35
- const fs = {
36
- resolve: async path => path,
37
- readText: async () => stored,
38
- writeText: async (_target, text) => { stored = text },
39
- }
40
- const receiver = { authorizedPath: async path => path, fs: () => fs }
41
- const adopted = adoptRecommendation('standard')
42
- const written = await Controller.prototype.write.call(receiver, { path: 'project', ...adopted })
43
- assert.equal(written.ok, true)
44
- const onDisk = JSON.parse(stored)
45
- assert.ok(onDisk.stage_bindings['开发'].skill_refs.length > 0)
46
- assert.equal(onDisk.stage_bindings['开发'].skills, undefined)
47
- assert.ok(onDisk.skill_profiles['bundled:code-implement'].rules.length > 0)
48
- const loaded = await Controller.prototype.read.call(receiver, 'project')
49
- assert.equal(loaded.ok, true)
50
- assert.deepEqual(loaded.config.stage_bindings, written.config.stage_bindings)
51
- assert.deepEqual(loaded.config.skill_profiles, written.config.skill_profiles)
52
- })
53
-
54
- test('an explicit skill profile governs every stage even when inline bindings are stale', () => {
55
- const skill = { source: 'project', name: 'my-skill' }
56
- const rule = { source: 'project', name: 'my-rule' }
57
- const project = { flow: 'minimal',
58
- stage_bindings: {
59
- '开发': { skills: [{ skill, rules: [] }] },
60
- '交付': { skills: [{ skill, rules: [] }] },
61
- },
62
- skill_profiles: { 'project:my-skill': { rules: [rule] } },
63
- }
64
- const resolved = resolveFlow('minimal', project)
65
- assert.equal(resolved.ok, true)
66
- assert.deepEqual(validateWorkflow(resolved.config), [])
67
- assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
68
- assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
69
- const compact = compactProjectConfig(project)
70
- assert.deepEqual(compact.stage_bindings['开发'].skill_refs, [skill])
71
- assert.deepEqual(resolveFlow('minimal', compact).config.stage_bindings['交付'].skills[0].rules, [rule])
72
- })
73
-
74
- test('canonical references to one skill share its rule list without a false conflict', () => {
75
- const skill = { source: 'project', name: 'my-skill' }
76
- const rule = { source: 'project', name: 'my-rule' }
77
- const resolved = resolveFlow('minimal', { flow: 'minimal', stage_bindings: {
78
- '开发': { skill_refs: [skill] },
79
- '交付': { skill_refs: [skill] },
80
- }, skill_profiles: { 'project:my-skill': { rules: [rule] } } })
81
- assert.deepEqual(validateWorkflow(resolved.config), [])
82
- assert.deepEqual(resolved.config.stage_bindings['开发'].skills[0].rules, [rule])
83
- assert.deepEqual(resolved.config.stage_bindings['交付'].skills[0].rules, [rule])
84
- })
85
-
86
- test('skill editor save persists one changed profile and read expands it into every stage', async () => {
87
- let stored
88
- const fs = { resolve: async path => path, readText: async () => stored,
89
- writeText: async (_target, content) => { stored = content } }
90
- const receiver = { authorizedPath: async path => path, fs: () => fs }
91
- const skill = { source: 'project', name: 'my-skill' }
92
- const rule = { source: 'project', name: 'my-rule' }
93
- const initial = compactProjectConfig({ flow: 'minimal', stage_bindings: {
94
- '开发': { skills: [{ skill, rules: [] }] },
95
- '交付': { skills: [{ skill, rules: [] }] },
96
- } })
97
- const edited = { ...initial, skill_profiles: { ...initial.skill_profiles,
98
- 'project:my-skill': { rules: [rule], evidence: 'none' } } }
99
- const written = await Controller.prototype.write.call(receiver, { path: 'project', ...edited })
100
- assert.equal(written.ok, true)
101
- assert.deepEqual(JSON.parse(stored).stage_bindings['开发'].skill_refs, [skill])
102
- const loaded = await Controller.prototype.read.call(receiver, 'project')
103
- assert.equal(loaded.ok, true)
104
- assert.deepEqual(loaded.config.stage_bindings['开发'].skills[0].rules, [rule])
105
- assert.deepEqual(loaded.config.stage_bindings['交付'].skills[0].rules, [rule])
106
- })
107
-
108
- test('adopting a recommendation retains the user-owned skill profiles', () => {
109
- const custom = { 'project:my-skill': { rules: [{ source: 'project', name: 'my-rule' }], evidence: 'manual' } }
110
- const adopted = adoptRecommendation('standard', { flow: 'standard', skill_profiles: custom })
111
- assert.deepEqual(adopted.skill_profiles, custom)
112
- })
113
-
114
- test('malformed canonical skill profiles fail validation without crashing resolution', () => {
115
- const malformed = resolveFlow('standard', { flow: 'standard',
116
- stage_bindings: { '开发': { skill_refs: [{ source: 'project', name: 'my-skill' }] } },
117
- skill_profiles: { 'project:my-skill': { rules: 'not-an-array' } },
118
- })
119
- assert.ok(validateWorkflow(malformed.config).some(problem => problem.includes('rules array')))
120
- })
121
-
122
- test('commit_required false also removes the checkpoint exit requirement', () => {
123
- const adopted = adoptRecommendation('standard')
124
- const config = resolveFlow('standard', { ...adopted, commit_required: false }).config
125
- const state = { stage: '代码审核', execution_version: 1, review: { outcome: 'pass' },
126
- artifacts: { review: { conclusion: 'pass', issues: 'none' } }, commits: [] }
127
- assert.equal(assertAdvance(state, '完成', config).ok, true)
128
- })
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
+ })
129
268
 
130
269
  test('round-trip: every configurable field survives being written and read back', () => {
131
270
  // The save path serialises the payload and the read path parses it back through
@@ -175,4 +314,4 @@ test('round-trip: the payload the panel sends contains every edited field', () =
175
314
  // And the whole payload must be accepted by the resolver the write path uses.
176
315
  const resolved = resolveFlow(payload.flow, payload)
177
316
  assert.equal(resolved.ok, true, 'the payload the panel sends must resolve without problems')
178
- })
317
+ })