@godv61/dsh-task-engine 0.25.0 → 0.26.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 +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 +7 -7
  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 +12 -0
  17. package/docs/README.md +40 -40
  18. package/docs/configuration.md +12 -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 +31 -26
  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 +825 -570
  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 +79 -11
  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 +21 -21
  49. package/preset/enable.mjs +87 -87
  50. package/preset/persona.md +4 -4
  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-verify/SKILL.md +3 -3
package/.acceptance.mjs CHANGED
@@ -1,151 +1,151 @@
1
- /**
2
- * Independent acceptance check for the skeleton/recommendation design.
3
- *
4
- * Reads the SHIPPED config and the built engine, not the test files, so a pass
5
- * cannot come from an assertion written to match the implementation.
6
- *
7
- * The criteria are the product principles rather than implementation details:
8
- * 1. a flow is state control only — adopting a setup is a separate act
9
- * 2. adoption yields a config the user fully owns, editable and removable
10
- * 3. a node references skills; a rule belongs to the skill that carries it
11
- * 4. nothing is merged back after adoption, and an upgrade never overwrites
12
- */
13
- import { readFileSync } from 'node:fs'
14
- import { resolveFlow, FLOW_PRESETS, adoptRecommendation } from './lib/workflows.js'
15
- import { validateWorkflow, formatResourceRef, completionBlockers, terminalRequirements, invalidatedBy } from './lib/engine.js'
16
-
17
- let failures = 0
18
- const check = (label, ok, detail = '') => {
19
- console.log(' ' + (ok ? '✅' : '❌') + ' ' + label + (detail ? ' — ' + detail : ''))
20
- if (!ok) failures++
21
- }
22
-
23
- console.log('=== 1. 流程只做状态控制 ===')
24
- for (const [id, preset] of Object.entries(FLOW_PRESETS)) {
25
- const skeleton = resolveFlow(id, { flow: id }).config
26
- check(`${id} 骨架不带技能`, Object.keys(skeleton.stage_bindings ?? {}).length === 0)
27
- check(`${id} 骨架不带产物`, skeleton.artifacts.length === 0)
28
- check(`${id} 骨架保留状态机`, preset.config.transitions.length > 0 && preset.config.stages.length > 0)
29
- check(`${id} 提交文本约定不在骨架里`, skeleton.commit.message_pattern === '', JSON.stringify(skeleton.commit.message_pattern))
30
- }
31
- // When a commit gate fires and whether the file scope is protected are flow
32
- // control, so they must stay on the skeleton: they are a safety floor, not a
33
- // writing convention, and must not disappear because a user declined a setup.
34
- check('standard 骨架保留提交检查点', FLOW_PRESETS.standard.config.commit.checkpoints.length > 0,
35
- JSON.stringify(FLOW_PRESETS.standard.config.commit.checkpoints))
36
- check('standard 骨架保留文件范围保护', FLOW_PRESETS.standard.config.commit.file_scope === true)
37
-
38
- console.log('')
39
- console.log('=== 2. 采用是显式动作 ===')
40
- check('推荐配置单独存在', Object.values(FLOW_PRESETS).every(p => p.recommendation !== undefined))
41
- check('选流程不产生绑定', Object.keys(resolveFlow('standard', { flow: 'standard' }).config.stage_bindings ?? {}).length === 0)
42
-
43
- console.log('')
44
- console.log('=== 3. 采用后完全可编辑、可删除 ===')
45
- for (const id of ['standard', 'agile', 'minimal']) {
46
- const adopted = adoptRecommendation(id)
47
- check(`${id} 采用后带推荐技能`, Object.keys(adopted.stage_bindings ?? {}).length > 0)
48
- const replaced = resolveFlow(id, { ...adopted, stage_bindings: {} }).config
49
- check(`${id} 清空后为空(不补回)`, Object.keys(replaced.stage_bindings ?? {}).length === 0)
50
- const mine = resolveFlow(id, {
51
- ...adopted,
52
- stage_bindings: { 开发: { skills: [{ skill: { source: 'project', name: 'mine' }, rules: [] }] } },
53
- }).config
54
- const dev = mine.stage_bindings['开发'].skills ?? []
55
- check(`${id} 用户技能被原样采纳`, dev.length === 1 && dev[0].skill.name === 'mine',
56
- JSON.stringify(dev.map(s => s.skill.name)))
57
- }
58
-
59
- console.log('')
60
- console.log('=== 4. 规则归属技能,节点只引用 ===')
61
- for (const id of ['standard', 'agile', 'minimal']) {
62
- const cfg = resolveFlow(id, adoptRecommendation(id)).config
63
- let stageRuleList = false
64
- let badSkill = false
65
- for (const binding of Object.values(cfg.stage_bindings ?? {})) {
66
- if (binding.rules !== undefined) stageRuleList = true
67
- for (const entry of binding.skills ?? []) {
68
- if (!Array.isArray(entry.rules)) badSkill = true
69
- if (entry.skill?.source === undefined) badSkill = true
70
- }
71
- }
72
- check(`${id} 节点无规则列表`, !stageRuleList)
73
- check(`${id} 每个技能自带规则数组`, !badSkill)
74
- }
75
-
76
- console.log('')
77
- console.log('=== 5. 同一技能引用在任何节点规则一致 ===')
78
- for (const id of ['standard', 'agile', 'minimal']) {
79
- const cfg = resolveFlow(id, adoptRecommendation(id)).config
80
- const bySkill = new Map()
81
- for (const binding of Object.values(cfg.stage_bindings ?? {})) {
82
- for (const entry of binding.skills ?? []) {
83
- const key = formatResourceRef(entry.skill)
84
- const sig = entry.rules.map(formatResourceRef).sort().join('|')
85
- if (!bySkill.has(key)) bySkill.set(key, new Set())
86
- bySkill.get(key).add(sig)
87
- }
88
- }
89
- let ok = true
90
- for (const [skill, sigs] of bySkill) if (sigs.size > 1) { ok = false; console.log(' ' + skill + ' 有 ' + sigs.size + ' 种规则集') }
91
- check(`${id} 每个技能的规则集唯一`, ok, bySkill.size + ' 个技能')
92
- }
93
-
94
- console.log('')
95
- console.log('=== 6. 配置可被引擎接受 ===')
96
- for (const id of ['standard', 'agile', 'minimal']) {
97
- const problems = validateWorkflow(resolveFlow(id, adoptRecommendation(id)).config)
98
- check(`${id} 采用后配置无问题`, problems.length === 0, problems.join('; '))
99
- const bare = validateWorkflow(resolveFlow(id, { flow: id }).config)
100
- check(`${id} 裸骨架也合法`, bare.length === 0, bare.join('; '))
101
- }
102
-
103
- console.log('')
104
- console.log('=== 7. defaults/eng.json 是采用后的配置示例 ===')
105
- const doc = JSON.parse(readFileSync('defaults/eng.json', 'utf8'))
106
- const merged = resolveFlow(doc.flow, doc)
107
- check('defaults/eng.json 可解析', merged.ok)
108
- if (merged.ok) {
109
- check('defaults/eng.json 无验证问题', validateWorkflow(merged.config).length === 0)
110
- check('defaults/eng.json 含技能(是采用后的样子)', Object.keys(merged.config.stage_bindings ?? {}).length > 0)
111
- }
112
-
113
- console.log('')
114
- console.log('=== 8. 三预设都以 completed 收尾,且检查各自声明的节点结果 ===')
115
- for (const id of ['standard', 'agile', 'minimal']) {
116
- const cfg = resolveFlow(id, adoptRecommendation(id)).config
117
- const terminals = cfg.stages.filter(s => !cfg.transitions.some(t => t.from === s))
118
- // Every conclusion a flow could ask for, so each preset is judged on what IT
119
- // declared rather than on what one preset happens to need.
120
- const satisfied = {
121
- stage: terminals[0], execution_version: 1,
122
- items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
123
- verification: { passed: true, evidence: [] },
124
- review: { outcome: 'pass' },
125
- commits: [{ label: 'TASK', hash: 'abc1234' }],
126
- }
127
- check(`${id} 未提交不可完成`, completionBlockers({ ...satisfied, commits: [] }, cfg).length > 0)
128
- check(`${id} 齐全后可完成`, completionBlockers(satisfied, cfg).length === 0,
129
- JSON.stringify(completionBlockers(satisfied, cfg)))
130
-
131
- // The result the assessment found could be missing: a flow that declares a review
132
- // must not complete with the review blocked, and a flow that declares none must
133
- // not be made to demand one.
134
- const wantsReview = terminalRequirements(cfg).includes('review_passed')
135
- const blocked = completionBlockers({ ...satisfied, review: { outcome: 'blocked' } }, cfg)
136
- check(`${id} ${wantsReview ? '审核未通过不可完成' : '不发明审核要求'}`,
137
- wantsReview ? blocked.some(b => b.includes('passing review')) : blocked.length === 0,
138
- JSON.stringify(blocked))
139
- }
140
-
141
- console.log('')
142
- console.log('=== 9. 返工三种类型范围不同 ===')
143
- const r = invalidatedBy('requirement'), s = invalidatedBy('solution'), d = invalidatedBy('defect')
144
- check('requirement 失效需求确认', r.includes('requirement_confirmation'))
145
- check('solution 保留需求确认', !s.includes('requirement_confirmation'))
146
- check('defect 保留需求与方案确认', !d.includes('requirement_confirmation') && !d.includes('solution_confirmation'))
147
- check('三者范围互不相同', new Set([r.join(), s.join(), d.join()]).size === 3)
148
-
149
- console.log('')
150
- console.log(failures === 0 ? '✅ 全部验收项通过' : `❌ ${failures} 项未通过`)
1
+ /**
2
+ * Independent acceptance check for the skeleton/recommendation design.
3
+ *
4
+ * Reads the SHIPPED config and the built engine, not the test files, so a pass
5
+ * cannot come from an assertion written to match the implementation.
6
+ *
7
+ * The criteria are the product principles rather than implementation details:
8
+ * 1. a flow is state control only — adopting a setup is a separate act
9
+ * 2. adoption yields a config the user fully owns, editable and removable
10
+ * 3. a node references skills; a rule belongs to the skill that carries it
11
+ * 4. nothing is merged back after adoption, and an upgrade never overwrites
12
+ */
13
+ import { readFileSync } from 'node:fs'
14
+ import { resolveFlow, FLOW_PRESETS, adoptRecommendation } from './lib/workflows.js'
15
+ import { validateWorkflow, formatResourceRef, completionBlockers, terminalRequirements, invalidatedBy } from './lib/engine.js'
16
+
17
+ let failures = 0
18
+ const check = (label, ok, detail = '') => {
19
+ console.log(' ' + (ok ? '✅' : '❌') + ' ' + label + (detail ? ' — ' + detail : ''))
20
+ if (!ok) failures++
21
+ }
22
+
23
+ console.log('=== 1. 流程只做状态控制 ===')
24
+ for (const [id, preset] of Object.entries(FLOW_PRESETS)) {
25
+ const skeleton = resolveFlow(id, { flow: id }).config
26
+ check(`${id} 骨架不带技能`, Object.keys(skeleton.stage_bindings ?? {}).length === 0)
27
+ check(`${id} 骨架不带产物`, skeleton.artifacts.length === 0)
28
+ check(`${id} 骨架保留状态机`, preset.config.transitions.length > 0 && preset.config.stages.length > 0)
29
+ check(`${id} 提交文本约定不在骨架里`, skeleton.commit.message_pattern === '', JSON.stringify(skeleton.commit.message_pattern))
30
+ }
31
+ // When a commit gate fires and whether the file scope is protected are flow
32
+ // control, so they must stay on the skeleton: they are a safety floor, not a
33
+ // writing convention, and must not disappear because a user declined a setup.
34
+ check('standard 骨架保留提交检查点', FLOW_PRESETS.standard.config.commit.checkpoints.length > 0,
35
+ JSON.stringify(FLOW_PRESETS.standard.config.commit.checkpoints))
36
+ check('standard 骨架保留文件范围保护', FLOW_PRESETS.standard.config.commit.file_scope === true)
37
+
38
+ console.log('')
39
+ console.log('=== 2. 采用是显式动作 ===')
40
+ check('推荐配置单独存在', Object.values(FLOW_PRESETS).every(p => p.recommendation !== undefined))
41
+ check('选流程不产生绑定', Object.keys(resolveFlow('standard', { flow: 'standard' }).config.stage_bindings ?? {}).length === 0)
42
+
43
+ console.log('')
44
+ console.log('=== 3. 采用后完全可编辑、可删除 ===')
45
+ for (const id of ['standard', 'agile', 'minimal']) {
46
+ const adopted = adoptRecommendation(id)
47
+ check(`${id} 采用后带推荐技能`, Object.keys(adopted.stage_bindings ?? {}).length > 0)
48
+ const replaced = resolveFlow(id, { ...adopted, stage_bindings: {} }).config
49
+ check(`${id} 清空后为空(不补回)`, Object.keys(replaced.stage_bindings ?? {}).length === 0)
50
+ const mine = resolveFlow(id, {
51
+ ...adopted,
52
+ stage_bindings: { 开发: { skills: [{ skill: { source: 'project', name: 'mine' }, rules: [] }] } },
53
+ }).config
54
+ const dev = mine.stage_bindings['开发'].skills ?? []
55
+ check(`${id} 用户技能被原样采纳`, dev.length === 1 && dev[0].skill.name === 'mine',
56
+ JSON.stringify(dev.map(s => s.skill.name)))
57
+ }
58
+
59
+ console.log('')
60
+ console.log('=== 4. 规则归属技能,节点只引用 ===')
61
+ for (const id of ['standard', 'agile', 'minimal']) {
62
+ const cfg = resolveFlow(id, adoptRecommendation(id)).config
63
+ let stageRuleList = false
64
+ let badSkill = false
65
+ for (const binding of Object.values(cfg.stage_bindings ?? {})) {
66
+ if (binding.rules !== undefined) stageRuleList = true
67
+ for (const entry of binding.skills ?? []) {
68
+ if (!Array.isArray(entry.rules)) badSkill = true
69
+ if (entry.skill?.source === undefined) badSkill = true
70
+ }
71
+ }
72
+ check(`${id} 节点无规则列表`, !stageRuleList)
73
+ check(`${id} 每个技能自带规则数组`, !badSkill)
74
+ }
75
+
76
+ console.log('')
77
+ console.log('=== 5. 同一技能引用在任何节点规则一致 ===')
78
+ for (const id of ['standard', 'agile', 'minimal']) {
79
+ const cfg = resolveFlow(id, adoptRecommendation(id)).config
80
+ const bySkill = new Map()
81
+ for (const binding of Object.values(cfg.stage_bindings ?? {})) {
82
+ for (const entry of binding.skills ?? []) {
83
+ const key = formatResourceRef(entry.skill)
84
+ const sig = entry.rules.map(formatResourceRef).sort().join('|')
85
+ if (!bySkill.has(key)) bySkill.set(key, new Set())
86
+ bySkill.get(key).add(sig)
87
+ }
88
+ }
89
+ let ok = true
90
+ for (const [skill, sigs] of bySkill) if (sigs.size > 1) { ok = false; console.log(' ' + skill + ' 有 ' + sigs.size + ' 种规则集') }
91
+ check(`${id} 每个技能的规则集唯一`, ok, bySkill.size + ' 个技能')
92
+ }
93
+
94
+ console.log('')
95
+ console.log('=== 6. 配置可被引擎接受 ===')
96
+ for (const id of ['standard', 'agile', 'minimal']) {
97
+ const problems = validateWorkflow(resolveFlow(id, adoptRecommendation(id)).config)
98
+ check(`${id} 采用后配置无问题`, problems.length === 0, problems.join('; '))
99
+ const bare = validateWorkflow(resolveFlow(id, { flow: id }).config)
100
+ check(`${id} 裸骨架也合法`, bare.length === 0, bare.join('; '))
101
+ }
102
+
103
+ console.log('')
104
+ console.log('=== 7. defaults/eng.json 是采用后的配置示例 ===')
105
+ const doc = JSON.parse(readFileSync('defaults/eng.json', 'utf8'))
106
+ const merged = resolveFlow(doc.flow, doc)
107
+ check('defaults/eng.json 可解析', merged.ok)
108
+ if (merged.ok) {
109
+ check('defaults/eng.json 无验证问题', validateWorkflow(merged.config).length === 0)
110
+ check('defaults/eng.json 含技能(是采用后的样子)', Object.keys(merged.config.stage_bindings ?? {}).length > 0)
111
+ }
112
+
113
+ console.log('')
114
+ console.log('=== 8. 三预设都以 completed 收尾,且检查各自声明的节点结果 ===')
115
+ for (const id of ['standard', 'agile', 'minimal']) {
116
+ const cfg = resolveFlow(id, adoptRecommendation(id)).config
117
+ const terminals = cfg.stages.filter(s => !cfg.transitions.some(t => t.from === s))
118
+ // Every conclusion a flow could ask for, so each preset is judged on what IT
119
+ // declared rather than on what one preset happens to need.
120
+ const satisfied = {
121
+ stage: terminals[0], execution_version: 1,
122
+ items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
123
+ verification: { passed: true, evidence: [] },
124
+ review: { outcome: 'pass' },
125
+ commits: [{ label: 'TASK', hash: 'abc1234' }],
126
+ }
127
+ check(`${id} 未提交不可完成`, completionBlockers({ ...satisfied, commits: [] }, cfg).length > 0)
128
+ check(`${id} 齐全后可完成`, completionBlockers(satisfied, cfg).length === 0,
129
+ JSON.stringify(completionBlockers(satisfied, cfg)))
130
+
131
+ // The result the assessment found could be missing: a flow that declares a review
132
+ // must not complete with the review blocked, and a flow that declares none must
133
+ // not be made to demand one.
134
+ const wantsReview = terminalRequirements(cfg).includes('review_passed')
135
+ const blocked = completionBlockers({ ...satisfied, review: { outcome: 'blocked' } }, cfg)
136
+ check(`${id} ${wantsReview ? '审核未通过不可完成' : '不发明审核要求'}`,
137
+ wantsReview ? blocked.some(b => b.includes('passing review')) : blocked.length === 0,
138
+ JSON.stringify(blocked))
139
+ }
140
+
141
+ console.log('')
142
+ console.log('=== 9. 返工三种类型范围不同 ===')
143
+ const r = invalidatedBy('requirement'), s = invalidatedBy('solution'), d = invalidatedBy('defect')
144
+ check('requirement 失效需求确认', r.includes('requirement_confirmation'))
145
+ check('solution 保留需求确认', !s.includes('requirement_confirmation'))
146
+ check('defect 保留需求与方案确认', !d.includes('requirement_confirmation') && !d.includes('solution_confirmation'))
147
+ check('三者范围互不相同', new Set([r.join(), s.join(), d.join()]).size === 3)
148
+
149
+ console.log('')
150
+ console.log(failures === 0 ? '✅ 全部验收项通过' : `❌ ${failures} 项未通过`)
151
151
  process.exit(failures === 0 ? 0 : 1)
@@ -17,21 +17,21 @@ import { registerDevTask } from './lib/dev-task.js'
17
17
  import { newTask, todosBlockers, completionBlockers, validateWorkflow, formatResourceRef } from './lib/engine.js'
18
18
  import { adoptRecommendation, FLOW_PRESETS } from './lib/workflows.js'
19
19
  import { resolveFlow } from './lib/workflows.js'
20
-
21
- /**
22
- * The preset as a project would actually run it: the skeleton plus the shipped
23
- * recommendation, adopted exactly the way a user adopts it. A test that asserts
24
- * on bindings, commit rules or artifacts wants this, because those no longer come
25
- * from the preset itself.
26
- * @param {string} id - preset id.
27
- * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
28
- */
29
- function adoptedFlow(id, extra) {
30
- const base = adoptRecommendation(id)
31
- if (base === undefined) throw new Error('unknown preset: ' + id)
32
- return resolveFlow(id, { ...base, ...extra }).config
33
- }
34
-
20
+
21
+ /**
22
+ * The preset as a project would actually run it: the skeleton plus the shipped
23
+ * recommendation, adopted exactly the way a user adopts it. A test that asserts
24
+ * on bindings, commit rules or artifacts wants this, because those no longer come
25
+ * from the preset itself.
26
+ * @param {string} id - preset id.
27
+ * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
28
+ */
29
+ function adoptedFlow(id, extra) {
30
+ const base = adoptRecommendation(id)
31
+ if (base === undefined) throw new Error('unknown preset: ' + id)
32
+ return resolveFlow(id, { ...base, ...extra }).config
33
+ }
34
+
35
35
 
36
36
  /** Build a fixture on one preset, mirroring .workflow-test.mjs's contract. */
37
37
  function fixture(preset = 'standard', stage, extra = {}, services = {}) {
@@ -365,11 +365,11 @@ test('P2: reaching the last stage is not completion for any preset', () => {
365
365
  execution_version: 1,
366
366
  items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
367
367
  verification: { passed: true, evidence: [] },
368
- // The flows that declare a review guard need a recorded verdict. That is the
369
- // point of deriving terminal requirements from the flow: standard and agile ask
370
- // for one, minimal does not, so this supplies what is asked for rather than
371
- // what one flow happens to need.
372
- review: { outcome: 'pass' },
368
+ // The flows that declare a review guard need a recorded verdict. That is the
369
+ // point of deriving terminal requirements from the flow: standard and agile ask
370
+ // for one, minimal does not, so this supplies what is asked for rather than
371
+ // what one flow happens to need.
372
+ review: { outcome: 'pass' },
373
373
  commits: [],
374
374
  }
375
375
  assert.ok(
package/.enforce-test.mjs CHANGED
@@ -1,139 +1,139 @@
1
- /**
2
- * The three enforcement gaps the assessment found, tested as behaviour.
3
- *
4
- * Each of these was previously DISCLOSED but not enforced, which is the same as not
5
- * being enforced: status reported the problem and the task proceeded anyway.
6
- */
7
- import test from 'node:test'
8
- import assert from 'node:assert/strict'
9
- import { join, resolve } from 'node:path'
10
- import { registerDevTask } from './lib/dev-task.js'
11
- import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
12
- import { completionBlockers, terminalRequirements, resourceBlockers } from './lib/engine.js'
13
-
14
- /** A project on the standard flow, one skill carrying a project rule, at 开发. */
15
- async function project() {
16
- const cwd = resolve('enforce-project')
17
- const adopted = adoptRecommendation('standard')
18
- const skills = adopted.stage_bindings['开发'].skills.map(entry => ({
19
- ...entry,
20
- rules: [...entry.rules, { source: 'project', name: 'mine' }],
21
- }))
22
- const stage_bindings = { ...adopted.stage_bindings, '开发': { skills } }
23
- const records = new Map([
24
- [join(cwd, '.dsh/eng.json'), JSON.stringify({ ...adopted, stage_bindings })],
25
- [join(cwd, '.dsh/rules/mine.md'), 'RULE'],
26
- [join(cwd, 'a.js'), 'source'],
27
- ])
28
- const events = []
29
- let execute
30
- const key = (p, options) => join(options?.cwd ?? cwd, p)
31
- const fs = {
32
- resolve: async (p, options) => ({ targetKey: key(p, options) }),
33
- readText: async target => records.get(target.targetKey),
34
- listDir: async () => [],
35
- lstat: async (p, options) => records.has(key(p, options)) ? { version: 'v' } : undefined,
36
- writeText: async (target, content) => { records.set(target.targetKey, content) },
37
- }
38
- const ctx = {
39
- fs,
40
- tools: { register(tool) { execute = tool.execute; return () => {} } },
41
- get: name => name === 'sandboxPolicy'
42
- ? { resolve: () => ({ mode: 'workspace-write', workspaceRoot: cwd, sessionId: 'e' }) }
43
- : undefined,
44
- }
45
- registerDevTask(ctx)
46
- const exec = { agent: { session: { id: 'e', header: { cwd }, snapshotEvents: () => events } }, signal: new AbortController().signal }
47
- const call = async args => execute(JSON.parse(JSON.stringify({ task_id: 'E-1', ...args })), exec)
48
- await call({ operation: 'create', title: 't', branch: 'main', files: ['a.js'] })
49
- const state = JSON.parse(records.get(join(cwd, '.dsh/task-E-1.json')))
50
- state.stage = '开发'
51
- state.requirement_confirmed = true
52
- state.solution_confirmed = true
53
- records.set(join(cwd, '.dsh/task-E-1.json'), JSON.stringify(state))
54
- const load = name => {
55
- const callId = 'c' + events.length
56
- events.push({ type: 'tool/call', data: { name: 'skill', callId, arguments: JSON.stringify({ name }) } })
57
- events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: callId, isError: false }] } } })
58
- }
59
- const current = () => JSON.parse(records.get(join(cwd, '.dsh/task-E-1.json')))
60
- return { call, records, cwd, load, current, rule: () => join(cwd, '.dsh/rules/mine.md') }
61
- }
62
-
63
- /** Satisfy everything 开发 → 交付 needs except the resource question. */
64
- async function satistfyDevelopment(f) {
65
- await f.call({ operation: 'items', items: [{ id: 'A', title: 'a', status: 'doing' }] })
66
- await f.call({ operation: 'dispatch', item_id: 'A', description: 'd' })
67
- await f.call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
68
- await f.call({ operation: 'items', items: [{ id: 'A', status: 'done' }] })
69
- for (const entry of f.current().flow.config.stage_bindings['开发'].skills) f.load(entry.skill.name)
70
- }
71
-
72
- test('enforce: a task WITHOUT a snapshot cannot advance while a bound rule is unresolvable', async () => {
73
- // The disclosed-but-unenforced case: status said the rule was missing and advance
74
- // still succeeded, so the stage completed without the constraint.
75
- const f = await project()
76
- const state = f.current()
77
- delete state.flow.resources
78
- f.records.set(join(f.cwd, '.dsh/task-E-1.json'), JSON.stringify(state))
79
- await satistfyDevelopment(f)
80
-
81
- f.records.delete(f.rule())
82
- await assert.rejects(
83
- () => f.call({ operation: 'advance', target_stage: '交付' }),
84
- /resolves nowhere/u,
85
- 'a stage whose rules cannot be resolved must not be passable',
86
- )
87
- })
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
- })
100
-
101
- test('enforce: completion requires the review and verification the flow declared', async () => {
102
- // Arriving at the last stage used to be enough. The agile flow declares a review
103
- // on the way into 审查, so a blocked review must now prevent completion —
104
- // without hardcoding review into flows that never asked for it.
105
- const agile = resolveFlow('agile', adoptRecommendation('agile')).config
106
- assert.ok(terminalRequirements(agile).length >= 0)
107
- const base = {
108
- stage: '审查', execution_version: 1,
109
- items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
110
- verification: { passed: true, evidence: [] },
111
- commits: [{ label: 'TASK', hash: 'abc1234' }],
112
- }
113
- assert.deepEqual(completionBlockers({ ...base, review: { outcome: 'pass' } }, agile), [],
114
- 'a passing review completes')
115
- const blocked = completionBlockers({ ...base, review: { outcome: 'blocked' } }, agile)
116
- assert.ok(blocked.some(b => b.includes('passing review')),
117
- `a blocked review must prevent completion; got ${JSON.stringify(blocked)}`)
118
-
119
- // And a flow that declares no verification gate is not made to demand one.
120
- const minimal = resolveFlow('minimal', adoptRecommendation('minimal')).config
121
- const minState = {
122
- stage: '交付', execution_version: 1,
123
- items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' } } }],
124
- verification: { passed: false, evidence: [] },
125
- commits: [{ label: 'TASK', hash: 'abc1234' }],
126
- }
127
- assert.deepEqual(completionBlockers(minState, minimal), [],
128
- 'minimal declares no verification gate, so a non-passing verification must not block it')
129
- })
130
-
131
- test('enforce: the resource blocker names the stage, the reference and the fix', () => {
132
- const blockers = resourceBlockers(['project:mine'], '开发')
133
- assert.equal(blockers.length, 1)
134
- assert.match(blockers[0], /开发/u)
135
- assert.match(blockers[0], /project:mine/u)
136
- assert.match(blockers[0], /Restore the file|remove the binding/u,
137
- 'a blocker must say what to do, not only what is wrong')
138
- assert.deepEqual(resourceBlockers([], '开发'), [], 'a resolvable stage has no blocker')
1
+ /**
2
+ * The three enforcement gaps the assessment found, tested as behaviour.
3
+ *
4
+ * Each of these was previously DISCLOSED but not enforced, which is the same as not
5
+ * being enforced: status reported the problem and the task proceeded anyway.
6
+ */
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { join, resolve } from 'node:path'
10
+ import { registerDevTask } from './lib/dev-task.js'
11
+ import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
12
+ import { completionBlockers, terminalRequirements, resourceBlockers } from './lib/engine.js'
13
+
14
+ /** A project on the standard flow, one skill carrying a project rule, at 开发. */
15
+ async function project() {
16
+ const cwd = resolve('enforce-project')
17
+ const adopted = adoptRecommendation('standard')
18
+ const skills = adopted.stage_bindings['开发'].skills.map(entry => ({
19
+ ...entry,
20
+ rules: [...entry.rules, { source: 'project', name: 'mine' }],
21
+ }))
22
+ const stage_bindings = { ...adopted.stage_bindings, '开发': { skills } }
23
+ const records = new Map([
24
+ [join(cwd, '.dsh/eng.json'), JSON.stringify({ ...adopted, stage_bindings })],
25
+ [join(cwd, '.dsh/rules/mine.md'), 'RULE'],
26
+ [join(cwd, 'a.js'), 'source'],
27
+ ])
28
+ const events = []
29
+ let execute
30
+ const key = (p, options) => join(options?.cwd ?? cwd, p)
31
+ const fs = {
32
+ resolve: async (p, options) => ({ targetKey: key(p, options) }),
33
+ readText: async target => records.get(target.targetKey),
34
+ listDir: async () => [],
35
+ lstat: async (p, options) => records.has(key(p, options)) ? { version: 'v' } : undefined,
36
+ writeText: async (target, content) => { records.set(target.targetKey, content) },
37
+ }
38
+ const ctx = {
39
+ fs,
40
+ tools: { register(tool) { execute = tool.execute; return () => {} } },
41
+ get: name => name === 'sandboxPolicy'
42
+ ? { resolve: () => ({ mode: 'workspace-write', workspaceRoot: cwd, sessionId: 'e' }) }
43
+ : undefined,
44
+ }
45
+ registerDevTask(ctx)
46
+ const exec = { agent: { session: { id: 'e', header: { cwd }, snapshotEvents: () => events } }, signal: new AbortController().signal }
47
+ const call = async args => execute(JSON.parse(JSON.stringify({ task_id: 'E-1', ...args })), exec)
48
+ await call({ operation: 'create', title: 't', branch: 'main', files: ['a.js'] })
49
+ const state = JSON.parse(records.get(join(cwd, '.dsh/task-E-1.json')))
50
+ state.stage = '开发'
51
+ state.requirement_confirmed = true
52
+ state.solution_confirmed = true
53
+ records.set(join(cwd, '.dsh/task-E-1.json'), JSON.stringify(state))
54
+ const load = name => {
55
+ const callId = 'c' + events.length
56
+ events.push({ type: 'tool/call', data: { name: 'skill', callId, arguments: JSON.stringify({ name }) } })
57
+ events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: callId, isError: false }] } } })
58
+ }
59
+ const current = () => JSON.parse(records.get(join(cwd, '.dsh/task-E-1.json')))
60
+ return { call, records, cwd, load, current, rule: () => join(cwd, '.dsh/rules/mine.md') }
61
+ }
62
+
63
+ /** Satisfy everything 开发 → 交付 needs except the resource question. */
64
+ async function satistfyDevelopment(f) {
65
+ await f.call({ operation: 'items', items: [{ id: 'A', title: 'a', status: 'doing' }] })
66
+ await f.call({ operation: 'dispatch', item_id: 'A', description: 'd' })
67
+ await f.call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
68
+ await f.call({ operation: 'items', items: [{ id: 'A', status: 'done' }] })
69
+ for (const entry of f.current().flow.config.stage_bindings['开发'].skills) f.load(entry.skill.name)
70
+ }
71
+
72
+ test('enforce: a task WITHOUT a snapshot cannot advance while a bound rule is unresolvable', async () => {
73
+ // The disclosed-but-unenforced case: status said the rule was missing and advance
74
+ // still succeeded, so the stage completed without the constraint.
75
+ const f = await project()
76
+ const state = f.current()
77
+ delete state.flow.resources
78
+ f.records.set(join(f.cwd, '.dsh/task-E-1.json'), JSON.stringify(state))
79
+ await satistfyDevelopment(f)
80
+
81
+ f.records.delete(f.rule())
82
+ await assert.rejects(
83
+ () => f.call({ operation: 'advance', target_stage: '交付' }),
84
+ /resolves nowhere/u,
85
+ 'a stage whose rules cannot be resolved must not be passable',
86
+ )
87
+ })
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
+ })
100
+
101
+ test('enforce: completion requires the review and verification the flow declared', async () => {
102
+ // Arriving at the last stage used to be enough. The agile flow declares a review
103
+ // on the way into 审查, so a blocked review must now prevent completion —
104
+ // without hardcoding review into flows that never asked for it.
105
+ const agile = resolveFlow('agile', adoptRecommendation('agile')).config
106
+ assert.ok(terminalRequirements(agile).length >= 0)
107
+ const base = {
108
+ stage: '审查', execution_version: 1,
109
+ items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
110
+ verification: { passed: true, evidence: [] },
111
+ commits: [{ label: 'TASK', hash: 'abc1234' }],
112
+ }
113
+ assert.deepEqual(completionBlockers({ ...base, review: { outcome: 'pass' } }, agile), [],
114
+ 'a passing review completes')
115
+ const blocked = completionBlockers({ ...base, review: { outcome: 'blocked' } }, agile)
116
+ assert.ok(blocked.some(b => b.includes('passing review')),
117
+ `a blocked review must prevent completion; got ${JSON.stringify(blocked)}`)
118
+
119
+ // And a flow that declares no verification gate is not made to demand one.
120
+ const minimal = resolveFlow('minimal', adoptRecommendation('minimal')).config
121
+ const minState = {
122
+ stage: '交付', execution_version: 1,
123
+ items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' } } }],
124
+ verification: { passed: false, evidence: [] },
125
+ commits: [{ label: 'TASK', hash: 'abc1234' }],
126
+ }
127
+ assert.deepEqual(completionBlockers(minState, minimal), [],
128
+ 'minimal declares no verification gate, so a non-passing verification must not block it')
129
+ })
130
+
131
+ test('enforce: the resource blocker names the stage, the reference and the fix', () => {
132
+ const blockers = resourceBlockers(['project:mine'], '开发')
133
+ assert.equal(blockers.length, 1)
134
+ assert.match(blockers[0], /开发/u)
135
+ assert.match(blockers[0], /project:mine/u)
136
+ assert.match(blockers[0], /Restore the file|remove the binding/u,
137
+ 'a blocker must say what to do, not only what is wrong')
138
+ assert.deepEqual(resourceBlockers([], '开发'), [], 'a resolvable stage has no blocker')
139
139
  })