@godv61/dsh-task-engine 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.acceptance.mjs +150 -150
  2. package/.assessment-batch1.mjs +20 -20
  3. package/.enforce-test.mjs +138 -138
  4. package/.evidence-test.mjs +118 -119
  5. package/.filter-test.mjs +92 -92
  6. package/.hook-test.mjs +195 -195
  7. package/.p0-test.mjs +59 -59
  8. package/.preset-test.mjs +160 -160
  9. package/.revision-test.mjs +30 -16
  10. package/.roundtrip-test.mjs +178 -63
  11. package/.workflow-test.mjs +86 -0
  12. package/README.md +8 -8
  13. package/cordis.patch.yml +10 -10
  14. package/defaults/eng.json +70 -22
  15. package/docs/BRIEF-FOR-REVIEW.md +162 -162
  16. package/docs/CHANGELOG.md +18 -0
  17. package/docs/README.md +40 -40
  18. package/docs/configuration.md +14 -9
  19. package/docs/development.md +1 -1
  20. package/docs/faq.md +4 -4
  21. package/docs/listing/godv61__dsh-task-engine.yml +5 -5
  22. package/docs/listing/submission.md +83 -83
  23. package/docs/manual.html +32 -27
  24. package/docs/releases/0.23.2.md +21 -21
  25. package/docs/roadmap.md +34 -34
  26. package/docs/testing/0.23.2/R02/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +56 -56
  27. package/docs/testing/0.23.2/R03/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +38 -38
  28. package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +65 -65
  29. package/hooks/commit-msg +70 -1
  30. package/lib/client.js +870 -571
  31. package/lib/client.js.map +3 -3
  32. package/lib/controller.d.ts +2 -1
  33. package/lib/controller.js +6 -3
  34. package/lib/controller.js.map +1 -1
  35. package/lib/dev-task.js +83 -15
  36. package/lib/dev-task.js.map +1 -1
  37. package/lib/engine.d.ts +15 -0
  38. package/lib/engine.js +29 -5
  39. package/lib/engine.js.map +1 -1
  40. package/lib/hook.js +3 -0
  41. package/lib/hook.js.map +1 -1
  42. package/lib/skill-audit.js +3 -3
  43. package/lib/skill-audit.js.map +1 -1
  44. package/lib/workflows.d.ts +4 -1
  45. package/lib/workflows.js +90 -2
  46. package/lib/workflows.js.map +1 -1
  47. package/package.json +1 -1
  48. package/preset/agent.cordis.yml +22 -22
  49. package/preset/enable.mjs +87 -87
  50. package/preset/persona.md +5 -5
  51. package/preset/preset.yml +1 -1
  52. package/rules/coding-conventions.md +6 -6
  53. package/rules/commit-conventions.md +6 -6
  54. package/rules/security-redlines.md +5 -5
  55. package/scripts/verify-dsh-compat.mjs +144 -144
  56. package/skills/code-implement/SKILL.md +1 -1
  57. package/skills/code-verify/SKILL.md +3 -3
  58. package/skills/eng-delivery/SKILL.md +10 -31
package/.preset-test.mjs CHANGED
@@ -1,161 +1,161 @@
1
- /**
2
- * Tests for the `eng` preset derivation.
3
- *
4
- * The preset must track the RUNNING harness's `standard`, because a copy that
5
- * freezes once goes stale the moment DSH edits its own composition, and a stale
6
- * preset fails to load entirely. Two real regressions motivate these cases:
7
- *
8
- * - DSH 2026-09-06 moved the persona row from `text:` to `prefix`/`suffix`.
9
- * - DSH 2026-09-13 replaced the `workflow-worker-thread` row with
10
- * `workflow-ptc`; the plugin kept emitting the removed row, whose package
11
- * had also left `apps/cli`'s dependencies, so the roster could not resolve
12
- * it and reported "加载失败" for the whole preset.
13
- *
14
- * Run: `node --test .preset-test.mjs`
15
- * @module dsh-task-engine/.preset-test
16
- */
17
-
18
- import assert from 'node:assert/strict'
19
- import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
20
- import { tmpdir } from 'node:os'
21
- import { join, resolve, sep } from 'node:path'
22
- import { test } from 'node:test'
23
- import { derivedEngComposition, wasSeeded, applyPersona, personaFieldOf } from './lib/seed-preset.js'
24
-
25
- /**
26
- * The live derivation resolves `@deepseek-ai/dsh-agent-presets`, a PEER the
27
- * running harness provides. In a consumer's install it may be absent or a
28
- * different version, so the assertions that read a real `standard` skip when it
29
- * is unavailable rather than failing —the fixture-driven cases below cover the
30
- * logic itself and always run.
31
- */
32
- const derived = derivedEngComposition()
33
- const liveSource = derived !== undefined
34
- const live = { skip: liveSource ? false : 'no resolvable @deepseek-ai/dsh-agent-presets in this install' }
35
-
36
- test('the derivation reads a readable standard composition', live, () => {
37
- assert.ok(derived !== undefined, 'standard must be resolvable when a peer provides it')
38
- assert.ok(derived.text.length > 0, 'the derived composition must not be empty')
39
- })
40
-
41
- test('the engineering persona replaces the shipped one', live, () => {
42
- assert.equal(derived.personaApplied, true, 'the persona row must be found and rewritten')
43
- assert.match(derived.text, /engineering-delivery coding agent/u, 'the persona body must be the engineering one')
44
- // The shipped prose must be gone, in whichever field the source used.
45
- assert.doesNotMatch(derived.text, /You are a coding agent powered by the \{\{model\}\} model/u,
46
- 'the shipped persona prose must be replaced, not merely shadowed')
47
- })
48
-
49
- test('the derived persona row is well formed and never sets complete', live, () => {
50
- // Which field is correct depends on the running harness, so assert the SHAPE
51
- // (a block scalar under `config:`) rather than one field name —the field
52
- // itself is pinned by the two fixtures below.
53
- assert.match(derived.text, /- id: persona\n {2}name: '@deepseek-ai\/dsh-persona'\n {2}config:\n(?: {4}suffix: .*\n)? {4}(?:text|prefix): [|>]-/u,
54
- 'the persona row must carry a block scalar under config')
55
- // `complete: true` would make this prose the ENTIRE system prompt and
56
- // suppress every other section, silently gutting the agent.
57
- assert.doesNotMatch(derived.text, /complete: true/u, 'complete must not be set')
58
- })
59
-
60
- test('the agent row is appended exactly once', live, () => {
61
- const matches = derived.text.match(/id: task-engine-agent/gu) ?? []
62
- assert.equal(matches.length, 1, 'task-engine-agent must appear once')
63
- assert.match(derived.text, /name: '@godv61\/dsh-task-engine\/agent'/u, 'the row must name the agent entry')
64
- })
65
-
66
- test('every top-level row of standard survives the derivation', live, () => {
67
- // The point of deriving rather than hand-writing: the preset must inherit the
68
- // harness's whole tool roster, not a remembered subset of it.
69
- const ids = (text) => (text.match(/^- id: (\S+)$/gmu) ?? []).map(line => line.replace(/^- id: /u, ''))
70
- const derivedIds = ids(derived.text)
71
- assert.ok(derivedIds.length > 10, `expected the full roster, saw ${derivedIds.length} rows`)
72
- assert.ok(derivedIds.includes('persona'), 'persona must be present')
73
- assert.equal(new Set(derivedIds).size, derivedIds.length, 'row ids must be unique')
74
- })
75
-
76
- test('a composition carrying the agent row is recognised as seeded', () => {
77
- assert.equal(wasSeeded(" - id: task-engine-agent\n name: 'x'\n"), true, 'the agent row marks a seeded file')
78
- assert.equal(wasSeeded('- id: persona\n name: y\n'), false, 'a hand-authored composition is not ours')
79
- })
80
-
81
- test('re-deriving is stable: the same standard yields the same text', live, () => {
82
- // Idempotence is what lets the seed run on every boot without rewriting.
83
- const again = derivedEngComposition()
84
- assert.equal(again.text, derived.text, 'two derivations of one source must agree')
85
- })
86
-
87
- // The persona plugin renamed `text` to `prefix`/`suffix` in DSH commit 40792330c0
88
- // (2026-09-06, first shipped in dsh-v0.1.3-alpha.2). The two schemas are mutually
89
- // exclusive: a preset written with `prefix` is rejected by an older persona
90
- // plugin with "$.text missing required value", and vice versa. The seed must
91
- // therefore follow whichever field the SOURCE composition uses, which is what
92
- // these two fixtures pin.
93
- const PERSONA_FIXTURE_LEGACY = [
94
- '- id: persona',
95
- " name: '@deepseek-ai/dsh-persona'",
96
- ' config:',
97
- ' text: >-',
98
- ' You are a coding agent powered by the {{model}} model.',
99
- '',
100
- '- id: agent-instructions',
101
- " name: '@deepseek-ai/dsh-agent-instructions'",
102
- '',
103
- ].join('\n')
104
-
105
- const PERSONA_FIXTURE_MODERN = [
106
- '- id: persona',
107
- " name: '@deepseek-ai/dsh-persona'",
108
- ' config:',
109
- ' suffix: Your working directory is {{cwd}}.',
110
- ' prefix: >-',
111
- ' You are a coding agent powered by the {{model}} model.',
112
- '',
113
- '- id: agent-instructions',
114
- " name: '@deepseek-ai/dsh-agent-instructions'",
115
- '',
116
- ].join('\n')
117
-
118
- /** Read the scalar keys of a persona row's `config:` mapping. */
119
- function personaConfig(text) {
120
- const lines = text.split('\n')
121
- const start = lines.findIndex(line => line.trimEnd() === '- id: persona')
122
- assert.ok(start >= 0, 'the fixture must carry a persona row')
123
- let end = start + 1
124
- while (end < lines.length && !/^- /u.test(lines[end] ?? '')) end++
125
- const config = {}
126
- for (const line of lines.slice(start, end)) {
127
- const match = /^ {4}([a-z]+):(.*)$/u.exec(line)
128
- if (match !== null) config[match[1]] = match[2].trim()
129
- }
130
- return config
131
- }
132
-
133
- test('the persona field follows the source schema, both generations', () => {
134
- assert.equal(personaFieldOf(PERSONA_FIXTURE_LEGACY), 'text', 'a text-based source is detected')
135
- assert.equal(personaFieldOf(PERSONA_FIXTURE_MODERN), 'prefix', 'a prefix-based source is detected')
136
-
137
- const legacy = applyPersona(PERSONA_FIXTURE_LEGACY, 'ENGINEER')
138
- assert.equal(legacy.applied, true, 'the legacy persona row is rewritten')
139
- const legacyConfig = personaConfig(legacy.text)
140
- assert.equal(legacyConfig.text, '>-', 'the legacy shape keeps `text`, never `prefix`')
141
- assert.equal(legacyConfig.prefix, undefined, 'no prefix field is introduced for an old harness')
142
-
143
- const modern = applyPersona(PERSONA_FIXTURE_MODERN, 'ENGINEER')
144
- const modernConfig = personaConfig(modern.text)
145
- assert.equal(modernConfig.prefix, '>-', 'the modern shape uses `prefix`')
146
- assert.equal(modernConfig.text, undefined, 'no text field is introduced for a new harness')
147
- assert.equal(modernConfig.suffix, 'Your working directory is {{cwd}}.', 'the shipped suffix is carried over')
148
- })
149
-
150
- test('the rewritten persona row keeps valid indentation for both shapes', () => {
151
- // A mis-indented `suffix:` under `config:` silently changes the YAML shape,
152
- // so assert the exact lines rather than trusting the mapping parse alone.
153
- const modern = applyPersona(PERSONA_FIXTURE_MODERN, 'ENGINEER').text
154
- assert.match(modern, /- id: persona\n {2}name: '@deepseek-ai\/dsh-persona'\n {2}config:\n {4}suffix: /u,
155
- 'suffix sits at four spaces under config')
156
- assert.match(modern, /\n {4}prefix: [|>]-\n {6}ENGINEER\n/u, 'prefix and its body are indented correctly')
157
-
158
- const legacy = applyPersona(PERSONA_FIXTURE_LEGACY, 'ENGINEER').text
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
- 'the legacy row is indented correctly')
1
+ /**
2
+ * Tests for the `eng` preset derivation.
3
+ *
4
+ * The preset must track the RUNNING harness's `standard`, because a copy that
5
+ * freezes once goes stale the moment DSH edits its own composition, and a stale
6
+ * preset fails to load entirely. Two real regressions motivate these cases:
7
+ *
8
+ * - DSH 2026-09-06 moved the persona row from `text:` to `prefix`/`suffix`.
9
+ * - DSH 2026-09-13 replaced the `workflow-worker-thread` row with
10
+ * `workflow-ptc`; the plugin kept emitting the removed row, whose package
11
+ * had also left `apps/cli`'s dependencies, so the roster could not resolve
12
+ * it and reported "加载失败" for the whole preset.
13
+ *
14
+ * Run: `node --test .preset-test.mjs`
15
+ * @module dsh-task-engine/.preset-test
16
+ */
17
+
18
+ import assert from 'node:assert/strict'
19
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { join, resolve, sep } from 'node:path'
22
+ import { test } from 'node:test'
23
+ import { derivedEngComposition, wasSeeded, applyPersona, personaFieldOf } from './lib/seed-preset.js'
24
+
25
+ /**
26
+ * The live derivation resolves `@deepseek-ai/dsh-agent-presets`, a PEER the
27
+ * running harness provides. In a consumer's install it may be absent or a
28
+ * different version, so the assertions that read a real `standard` skip when it
29
+ * is unavailable rather than failing —the fixture-driven cases below cover the
30
+ * logic itself and always run.
31
+ */
32
+ const derived = derivedEngComposition()
33
+ const liveSource = derived !== undefined
34
+ const live = { skip: liveSource ? false : 'no resolvable @deepseek-ai/dsh-agent-presets in this install' }
35
+
36
+ test('the derivation reads a readable standard composition', live, () => {
37
+ assert.ok(derived !== undefined, 'standard must be resolvable when a peer provides it')
38
+ assert.ok(derived.text.length > 0, 'the derived composition must not be empty')
39
+ })
40
+
41
+ test('the engineering persona replaces the shipped one', live, () => {
42
+ assert.equal(derived.personaApplied, true, 'the persona row must be found and rewritten')
43
+ assert.match(derived.text, /engineering-delivery coding agent/u, 'the persona body must be the engineering one')
44
+ // The shipped prose must be gone, in whichever field the source used.
45
+ assert.doesNotMatch(derived.text, /You are a coding agent powered by the \{\{model\}\} model/u,
46
+ 'the shipped persona prose must be replaced, not merely shadowed')
47
+ })
48
+
49
+ test('the derived persona row is well formed and never sets complete', live, () => {
50
+ // Which field is correct depends on the running harness, so assert the SHAPE
51
+ // (a block scalar under `config:`) rather than one field name —the field
52
+ // itself is pinned by the two fixtures below.
53
+ assert.match(derived.text, /- id: persona\n {2}name: '@deepseek-ai\/dsh-persona'\n {2}config:\n(?: {4}suffix: .*\n)? {4}(?:text|prefix): [|>]-/u,
54
+ 'the persona row must carry a block scalar under config')
55
+ // `complete: true` would make this prose the ENTIRE system prompt and
56
+ // suppress every other section, silently gutting the agent.
57
+ assert.doesNotMatch(derived.text, /complete: true/u, 'complete must not be set')
58
+ })
59
+
60
+ test('the agent row is appended exactly once', live, () => {
61
+ const matches = derived.text.match(/id: task-engine-agent/gu) ?? []
62
+ assert.equal(matches.length, 1, 'task-engine-agent must appear once')
63
+ assert.match(derived.text, /name: '@godv61\/dsh-task-engine\/agent'/u, 'the row must name the agent entry')
64
+ })
65
+
66
+ test('every top-level row of standard survives the derivation', live, () => {
67
+ // The point of deriving rather than hand-writing: the preset must inherit the
68
+ // harness's whole tool roster, not a remembered subset of it.
69
+ const ids = (text) => (text.match(/^- id: (\S+)$/gmu) ?? []).map(line => line.replace(/^- id: /u, ''))
70
+ const derivedIds = ids(derived.text)
71
+ assert.ok(derivedIds.length > 10, `expected the full roster, saw ${derivedIds.length} rows`)
72
+ assert.ok(derivedIds.includes('persona'), 'persona must be present')
73
+ assert.equal(new Set(derivedIds).size, derivedIds.length, 'row ids must be unique')
74
+ })
75
+
76
+ test('a composition carrying the agent row is recognised as seeded', () => {
77
+ assert.equal(wasSeeded(" - id: task-engine-agent\n name: 'x'\n"), true, 'the agent row marks a seeded file')
78
+ assert.equal(wasSeeded('- id: persona\n name: y\n'), false, 'a hand-authored composition is not ours')
79
+ })
80
+
81
+ test('re-deriving is stable: the same standard yields the same text', live, () => {
82
+ // Idempotence is what lets the seed run on every boot without rewriting.
83
+ const again = derivedEngComposition()
84
+ assert.equal(again.text, derived.text, 'two derivations of one source must agree')
85
+ })
86
+
87
+ // The persona plugin renamed `text` to `prefix`/`suffix` in DSH commit 40792330c0
88
+ // (2026-09-06, first shipped in dsh-v0.1.3-alpha.2). The two schemas are mutually
89
+ // exclusive: a preset written with `prefix` is rejected by an older persona
90
+ // plugin with "$.text missing required value", and vice versa. The seed must
91
+ // therefore follow whichever field the SOURCE composition uses, which is what
92
+ // these two fixtures pin.
93
+ const PERSONA_FIXTURE_LEGACY = [
94
+ '- id: persona',
95
+ " name: '@deepseek-ai/dsh-persona'",
96
+ ' config:',
97
+ ' text: >-',
98
+ ' You are a coding agent powered by the {{model}} model.',
99
+ '',
100
+ '- id: agent-instructions',
101
+ " name: '@deepseek-ai/dsh-agent-instructions'",
102
+ '',
103
+ ].join('\n')
104
+
105
+ const PERSONA_FIXTURE_MODERN = [
106
+ '- id: persona',
107
+ " name: '@deepseek-ai/dsh-persona'",
108
+ ' config:',
109
+ ' suffix: Your working directory is {{cwd}}.',
110
+ ' prefix: >-',
111
+ ' You are a coding agent powered by the {{model}} model.',
112
+ '',
113
+ '- id: agent-instructions',
114
+ " name: '@deepseek-ai/dsh-agent-instructions'",
115
+ '',
116
+ ].join('\n')
117
+
118
+ /** Read the scalar keys of a persona row's `config:` mapping. */
119
+ function personaConfig(text) {
120
+ const lines = text.split('\n')
121
+ const start = lines.findIndex(line => line.trimEnd() === '- id: persona')
122
+ assert.ok(start >= 0, 'the fixture must carry a persona row')
123
+ let end = start + 1
124
+ while (end < lines.length && !/^- /u.test(lines[end] ?? '')) end++
125
+ const config = {}
126
+ for (const line of lines.slice(start, end)) {
127
+ const match = /^ {4}([a-z]+):(.*)$/u.exec(line)
128
+ if (match !== null) config[match[1]] = match[2].trim()
129
+ }
130
+ return config
131
+ }
132
+
133
+ test('the persona field follows the source schema, both generations', () => {
134
+ assert.equal(personaFieldOf(PERSONA_FIXTURE_LEGACY), 'text', 'a text-based source is detected')
135
+ assert.equal(personaFieldOf(PERSONA_FIXTURE_MODERN), 'prefix', 'a prefix-based source is detected')
136
+
137
+ const legacy = applyPersona(PERSONA_FIXTURE_LEGACY, 'ENGINEER')
138
+ assert.equal(legacy.applied, true, 'the legacy persona row is rewritten')
139
+ const legacyConfig = personaConfig(legacy.text)
140
+ assert.equal(legacyConfig.text, '>-', 'the legacy shape keeps `text`, never `prefix`')
141
+ assert.equal(legacyConfig.prefix, undefined, 'no prefix field is introduced for an old harness')
142
+
143
+ const modern = applyPersona(PERSONA_FIXTURE_MODERN, 'ENGINEER')
144
+ const modernConfig = personaConfig(modern.text)
145
+ assert.equal(modernConfig.prefix, '>-', 'the modern shape uses `prefix`')
146
+ assert.equal(modernConfig.text, undefined, 'no text field is introduced for a new harness')
147
+ assert.equal(modernConfig.suffix, 'Your working directory is {{cwd}}.', 'the shipped suffix is carried over')
148
+ })
149
+
150
+ test('the rewritten persona row keeps valid indentation for both shapes', () => {
151
+ // A mis-indented `suffix:` under `config:` silently changes the YAML shape,
152
+ // so assert the exact lines rather than trusting the mapping parse alone.
153
+ const modern = applyPersona(PERSONA_FIXTURE_MODERN, 'ENGINEER').text
154
+ assert.match(modern, /- id: persona\n {2}name: '@deepseek-ai\/dsh-persona'\n {2}config:\n {4}suffix: /u,
155
+ 'suffix sits at four spaces under config')
156
+ assert.match(modern, /\n {4}prefix: [|>]-\n {6}ENGINEER\n/u, 'prefix and its body are indented correctly')
157
+
158
+ const legacy = applyPersona(PERSONA_FIXTURE_LEGACY, 'ENGINEER').text
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
+ 'the legacy row is indented correctly')
161
161
  })
@@ -12,21 +12,21 @@ import test from 'node:test'
12
12
  import assert from 'node:assert/strict'
13
13
  import { applyRevision, invalidatedBy, completionBlockers } from './lib/engine.js'
14
14
  import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
15
-
16
- /**
17
- * The preset as a project would actually run it: the skeleton plus the shipped
18
- * recommendation, adopted exactly the way a user adopts it. A test that asserts
19
- * on bindings, commit rules or artifacts wants this, because those no longer come
20
- * from the preset itself.
21
- * @param {string} id - preset id.
22
- * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
23
- */
24
- function adoptedFlow(id, extra) {
25
- const base = adoptRecommendation(id)
26
- if (base === undefined) throw new Error('unknown preset: ' + id)
27
- return resolveFlow(id, { ...base, ...extra }).config
28
- }
29
-
15
+
16
+ /**
17
+ * The preset as a project would actually run it: the skeleton plus the shipped
18
+ * recommendation, adopted exactly the way a user adopts it. A test that asserts
19
+ * on bindings, commit rules or artifacts wants this, because those no longer come
20
+ * from the preset itself.
21
+ * @param {string} id - preset id.
22
+ * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
23
+ */
24
+ function adoptedFlow(id, extra) {
25
+ const base = adoptRecommendation(id)
26
+ if (base === undefined) throw new Error('unknown preset: ' + id)
27
+ return resolveFlow(id, { ...base, ...extra }).config
28
+ }
29
+
30
30
 
31
31
  /** A task carrying every conclusion, so a rework has something to invalidate. */
32
32
  function loadedTask() {
@@ -111,4 +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
- })
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
+ })
@@ -1,63 +1,178 @@
1
- /**
2
- * Config round-trip: what the panel shows is what the file holds.
3
- *
4
- * The write path and the panel both carried only `flow` + `stage_bindings`, so a
5
- * commit rule, artifact declarations or a review depth were resolved for the preview
6
- * and then dropped on save. A user could set them, see them applied, and lose them.
7
- */
8
- import test from 'node:test'
9
- import assert from 'node:assert/strict'
10
- import { resolveFlow, adoptRecommendation } from './lib/workflows.js'
11
-
12
- /** The fields a project config can carry, so a round-trip is checked on all of them. */
13
- const FIELDS = ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth', 'commit_required']
14
-
15
- test('round-trip: every configurable field survives being written and read back', () => {
16
- // The save path serialises the payload and the read path parses it back through
17
- // resolveFlow, so this asserts exactly those two steps.
18
- const adopted = adoptRecommendation('standard')
19
- const written = {
20
- flow: 'standard',
21
- stage_bindings: adopted.stage_bindings,
22
- commit: adopted.commit,
23
- artifacts: adopted.artifacts,
24
- review_depth: 'single',
25
- commit_required: false,
26
- }
27
- const serialised = JSON.stringify(written, null, 2)
28
- const readBack = JSON.parse(serialised)
29
- for (const field of FIELDS) {
30
- assert.ok(field in readBack, `${field} must survive serialisation`)
31
- }
32
-
33
- const resolved = resolveFlow(readBack.flow, readBack)
34
- assert.equal(resolved.ok, true)
35
- assert.deepEqual(resolved.config.stage_bindings, written.stage_bindings, 'bindings round-trip')
36
- assert.equal(resolved.config.commit.message_pattern, written.commit.message_pattern, 'the commit text convention round-trips')
37
- assert.equal(resolved.config.commit.policy, 'task', 'the flow-control half of the commit rule is preserved')
38
- assert.deepEqual(resolved.config.artifacts, written.artifacts, 'artifacts round-trip')
39
- assert.equal(resolved.config.review_depth, 'single', 'review depth round-trips')
40
- assert.equal(resolved.config.commit_required, false, 'commit_required round-trips')
41
- })
42
-
43
- test('round-trip: the payload the panel sends contains every edited field', () => {
44
- // Mirrors the panel's save payload, so a field added to the config but not to the
45
- // payload is caught here rather than by a user losing their settings.
46
- const adopted = adoptRecommendation('standard')
47
- const commitRule = adopted.commit
48
- const artifacts = adopted.artifacts
49
- const reviewDepth = 'single'
50
- const payload = {
51
- flow: 'standard',
52
- stage_bindings: adopted.stage_bindings,
53
- ...(commitRule !== undefined ? { commit: commitRule } : {}),
54
- ...(artifacts !== undefined ? { artifacts } : {}),
55
- ...(reviewDepth !== undefined ? { review_depth: reviewDepth } : {}),
56
- }
57
- for (const field of ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth']) {
58
- assert.ok(field in payload, `the panel must send ${field}`)
59
- }
60
- // And the whole payload must be accepted by the resolver the write path uses.
61
- const resolved = resolveFlow(payload.flow, payload)
62
- assert.equal(resolved.ok, true, 'the payload the panel sends must resolve without problems')
63
- })
1
+ /**
2
+ * Config round-trip: what the panel shows is what the file holds.
3
+ *
4
+ * The write path and the panel both carried only `flow` + `stage_bindings`, so a
5
+ * commit rule, artifact declarations or a review depth were resolved for the preview
6
+ * and then dropped on save. A user could set them, see them applied, and lose them.
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'
13
+
14
+ /** 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
+ })
129
+
130
+ test('round-trip: every configurable field survives being written and read back', () => {
131
+ // The save path serialises the payload and the read path parses it back through
132
+ // resolveFlow, so this asserts exactly those two steps.
133
+ const adopted = adoptRecommendation('standard')
134
+ const written = {
135
+ flow: 'standard',
136
+ stage_bindings: adopted.stage_bindings,
137
+ commit: adopted.commit,
138
+ artifacts: adopted.artifacts,
139
+ review_depth: 'single',
140
+ commit_required: false,
141
+ }
142
+ const serialised = JSON.stringify(written, null, 2)
143
+ const readBack = JSON.parse(serialised)
144
+ for (const field of FIELDS) {
145
+ assert.ok(field in readBack, `${field} must survive serialisation`)
146
+ }
147
+
148
+ const resolved = resolveFlow(readBack.flow, readBack)
149
+ assert.equal(resolved.ok, true)
150
+ assert.deepEqual(resolved.config.stage_bindings, written.stage_bindings, 'bindings round-trip')
151
+ assert.equal(resolved.config.commit.message_pattern, written.commit.message_pattern, 'the commit text convention round-trips')
152
+ assert.equal(resolved.config.commit.policy, 'task', 'the flow-control half of the commit rule is preserved')
153
+ assert.deepEqual(resolved.config.artifacts, written.artifacts, 'artifacts round-trip')
154
+ assert.equal(resolved.config.review_depth, 'single', 'review depth round-trips')
155
+ assert.equal(resolved.config.commit_required, false, 'commit_required round-trips')
156
+ })
157
+
158
+ test('round-trip: the payload the panel sends contains every edited field', () => {
159
+ // Mirrors the panel's save payload, so a field added to the config but not to the
160
+ // payload is caught here rather than by a user losing their settings.
161
+ const adopted = adoptRecommendation('standard')
162
+ const commitRule = adopted.commit
163
+ const artifacts = adopted.artifacts
164
+ const reviewDepth = 'single'
165
+ const payload = {
166
+ flow: 'standard',
167
+ stage_bindings: adopted.stage_bindings,
168
+ ...(commitRule !== undefined ? { commit: commitRule } : {}),
169
+ ...(artifacts !== undefined ? { artifacts } : {}),
170
+ ...(reviewDepth !== undefined ? { review_depth: reviewDepth } : {}),
171
+ }
172
+ for (const field of ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth']) {
173
+ assert.ok(field in payload, `the panel must send ${field}`)
174
+ }
175
+ // And the whole payload must be accepted by the resolver the write path uses.
176
+ const resolved = resolveFlow(payload.flow, payload)
177
+ assert.equal(resolved.ok, true, 'the payload the panel sends must resolve without problems')
178
+ })