@godv61/dsh-task-engine 0.24.0 → 0.25.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/.acceptance.mjs CHANGED
@@ -1,89 +1,151 @@
1
- /**
2
- * Independent acceptance check against the corrected design.
3
- *
4
- * Reads the SHIPPED config and the built engine, not the test files, so a pass
5
- * here cannot come from an assertion I wrote to match my own implementation.
6
- */
7
- import { readFileSync } from 'node:fs'
8
- import { resolveFlow, FLOW_PRESETS } from './lib/workflows.js'
9
- import { validateWorkflow, bindingsForStage, completionBlockers, invalidatedBy } from './lib/engine.js'
10
-
11
- let failures = 0
12
- const check = (label, ok, detail = '') => {
13
- console.log(' ' + (ok ? '✅' : '❌') + ' ' + label + (detail ? ' — ' + detail : ''))
14
- if (!ok) failures++
15
- }
16
-
17
- console.log('=== 验收 1:节点只选技能,规则归技能 ===')
18
- for (const [id, preset] of Object.entries(FLOW_PRESETS)) {
19
- for (const [stage, b] of Object.entries(preset.config.stage_bindings ?? {})) {
20
- if ('rules' in b && b.rules !== undefined) {
21
- check(`${id}/${stage} 无节点级规则`, false, JSON.stringify(b.rules))
22
- }
23
- for (const e of b.skills ?? []) {
24
- if (!Array.isArray(e.rules)) check(`${id}/${stage} 技能带规则数组`, false)
25
- if (!e.skill?.source) check(`${id}/${stage} 技能有来源`, false, e.skill?.name)
26
- }
27
- }
28
- }
29
- check('全部节点只含 skills', failures === 0)
30
-
31
- console.log('')
32
- console.log('=== 验收 2:同一技能在任何节点规则一致 ===')
33
- const bySkill = new Map()
34
- for (const [id, preset] of Object.entries(FLOW_PRESETS)) {
35
- for (const [stage, b] of Object.entries(preset.config.stage_bindings ?? {})) {
36
- for (const e of b.skills ?? []) {
37
- const key = `${e.skill.source}:${e.skill.name}`
38
- const sig = e.rules.map(r => `${r.source}:${r.name}`).sort().join('|')
39
- if (!bySkill.has(key)) bySkill.set(key, new Set())
40
- bySkill.get(key).add(sig)
41
- }
42
- }
43
- }
44
- for (const [skill, sigs] of bySkill) {
45
- check(`${skill} 规则集唯一`, sigs.size === 1, sigs.size > 1 ? [...sigs].join(' vs ') : '')
46
- }
47
-
48
- console.log('')
49
- console.log('=== 验收 3:配置可被引擎接受 ===')
50
- for (const id of ['standard', 'agile', 'minimal']) {
51
- const problems = validateWorkflow(resolveFlow(id, {}).config)
52
- check(`${id} validateWorkflow 无问题`, problems.length === 0, problems.join('; '))
53
- }
54
-
55
- console.log('')
56
- console.log('=== 验收 4:默认配置示例与预设一致 ===')
57
- const doc = JSON.parse(readFileSync('defaults/eng.json', 'utf8'))
58
- const merged = resolveFlow('standard', { stage_bindings: doc.stage_bindings })
59
- check('defaults/eng.json 可解析', merged.ok)
60
- check('defaults/eng.json 无验证问题', validateWorkflow(merged.config).length === 0)
61
-
62
- console.log('')
63
- console.log('=== 验收 5:三预设都以 completed 收尾 ===')
64
- for (const id of ['standard', 'agile', 'minimal']) {
65
- const cfg = resolveFlow(id, {}).config
66
- const terminals = cfg.stages.filter(s => !cfg.transitions.some(t => t.from === s))
67
- const state = {
68
- stage: terminals[0], execution_version: 1,
69
- items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
70
- verification: { passed: true, evidence: [] }, commits: [],
71
- }
72
- const blocked = completionBlockers(state, cfg)
73
- check(`${id} 未提交时不可完成`, blocked.length > 0)
74
- const done = { ...state, commits: [{ label: 'TASK', hash: 'abc1234' }] }
75
- check(`${id} 提交后可完成`, completionBlockers(done, cfg).length === 0)
76
- }
77
-
78
- console.log('')
79
- console.log('=== 验收 6:返工三种类型范围不同 ===')
80
- const r = invalidatedBy('requirement'), s = invalidatedBy('solution'), d = invalidatedBy('defect')
81
- check('requirement 失效需求确认', r.includes('requirement_confirmation'))
82
- check('solution 保留需求确认', !s.includes('requirement_confirmation'))
83
- check('defect 保留需求与方案确认', !d.includes('requirement_confirmation') && !d.includes('solution_confirmation'))
84
- check('defect 失效验证与审核', d.includes('verification') && d.includes('review'))
85
- check('三者范围互不相同', new Set([r.join(), s.join(), d.join()]).size === 3)
86
-
87
- console.log('')
88
- 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} 项未通过`)
89
151
  process.exit(failures === 0 ? 0 : 1)
@@ -15,13 +15,28 @@ import assert from 'node:assert/strict'
15
15
  import { join, resolve } from 'node:path'
16
16
  import { registerDevTask } from './lib/dev-task.js'
17
17
  import { newTask, todosBlockers, completionBlockers, validateWorkflow, formatResourceRef } from './lib/engine.js'
18
- import { FLOW_PRESETS } from './lib/workflows.js'
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
35
 
21
36
  /** Build a fixture on one preset, mirroring .workflow-test.mjs's contract. */
22
37
  function fixture(preset = 'standard', stage, extra = {}, services = {}) {
23
38
  const cwd = resolve('test-project')
24
- const config = resolveFlow(preset, {}).config
39
+ const config = adoptedFlow(preset)
25
40
  const state = newTask({
26
41
  id: 'LIVE-1', title: 'assessment', branch: 'test', work_size: 'standard',
27
42
  risk_level: 'standard', flow: { flow: preset, version: config.version ?? 1, config }, root: cwd,
@@ -131,7 +146,7 @@ test('P1: the agile checkpoint label must satisfy the agile message pattern', as
131
146
  const f = fixture('agile', '交付', { items: [{ id: 'A', title: 'a', status: 'done' }] })
132
147
  const status = JSON.parse(await f.call({ operation: 'status' }))
133
148
  const label = status.commit?.label
134
- const pattern = resolveFlow('agile', {}).config.commit.message_pattern
149
+ const pattern = adoptedFlow('agile').commit.message_pattern
135
150
  assert.ok(label !== undefined, 'a commit must be due at the agile checkpoint')
136
151
  assert.match(
137
152
  `【LIVE-1】【${label}】did a thing`,
@@ -202,8 +217,8 @@ test('P2: the minimal preset reviews each item once, and still reviews it', () =
202
217
  // This earlier asserted on status.skill_blockers/commit_blockers, which never
203
218
  // contain todosBlockers output, so it passed against a completely unfixed
204
219
  // engine. Call the function under test directly instead.
205
- const minimal = resolveFlow('minimal', {}).config
206
- const standard = resolveFlow('standard', {}).config
220
+ const minimal = adoptedFlow('minimal')
221
+ const standard = adoptedFlow('standard')
207
222
  const specPassQualityFail = {
208
223
  items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
209
224
  }
@@ -230,7 +245,7 @@ test('P2: the three presets must not share one identical review burden', () => {
230
245
  items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
231
246
  }
232
247
  const byDepth = ['standard', 'agile', 'minimal'].map(id =>
233
- todosBlockers(state, resolveFlow(id, {}).config).length)
248
+ todosBlockers(state, adoptedFlow(id)).length)
234
249
  assert.ok(
235
250
  byDepth[2] < byDepth[0],
236
251
  `the fast-change flow must audit less per item than the full one; got ${JSON.stringify(byDepth)}`,
@@ -267,7 +282,7 @@ test('P2: a rule a skill declares but that resolves nowhere must be reported, no
267
282
  test('batch3: a stage selects skills only, and a skill carries its own complete rule list', () => {
268
283
  // The whole point of the model: opening a skill tells you every rule it runs
269
284
  // under, and binding it anywhere does not add or override any rule.
270
- const config = resolveFlow('standard', {}).config
285
+ const config = adoptedFlow('standard')
271
286
  for (const [stage, binding] of Object.entries(config.stage_bindings ?? {})) {
272
287
  assert.ok(
273
288
  !('rules' in binding) || binding.rules === undefined,
@@ -305,7 +320,7 @@ test('batch3: the same skill carries identical rules wherever it is bound', () =
305
320
  test('batch3: a rule shared by two skills is one resource, referenced twice', () => {
306
321
  // Reuse must not duplicate the rule body: two skills referencing one rule point
307
322
  // at the same source-qualified reference.
308
- const config = resolveFlow('standard', {}).config
323
+ const config = adoptedFlow('standard')
309
324
  const review = config.stage_bindings['代码审核'].skills
310
325
  const reviewRules = review.find(entry => entry.skill.name === 'code-review').rules.map(formatResourceRef)
311
326
  const implementRules = config.stage_bindings['开发'].skills
@@ -344,12 +359,17 @@ test('P2: reaching the last stage is not completion for any preset', () => {
344
359
  // flow could stand on its last stage with no delivery record at all. Completion
345
360
  // is now its own checked event, shared by all three presets.
346
361
  for (const [preset, stage] of [['standard', '完成'], ['agile', '审查'], ['minimal', '交付']]) {
347
- const config = resolveFlow(preset, {}).config
362
+ const config = adoptedFlow(preset)
348
363
  const state = {
349
364
  stage,
350
365
  execution_version: 1,
351
366
  items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
352
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' },
353
373
  commits: [],
354
374
  }
355
375
  assert.ok(
@@ -365,7 +385,7 @@ test('P2: reaching the last stage is not completion for any preset', () => {
365
385
  })
366
386
 
367
387
  test('P2: completion refuses a task that has not reached a final stage', () => {
368
- const config = resolveFlow('standard', {}).config
388
+ const config = adoptedFlow('standard')
369
389
  const state = {
370
390
  stage: '开发',
371
391
  execution_version: 1,
@@ -381,7 +401,7 @@ test('P2: completion refuses a task that has not reached a final stage', () => {
381
401
  test('P2: a flow may declare that its delivery mode needs no commit', () => {
382
402
  // A Git commit is one delivery mode, not a universal finish line: a non-code
383
403
  // task or a project outside version control has nothing to commit.
384
- const withoutCommit = { ...resolveFlow('minimal', {}).config, commit_required: false }
404
+ const withoutCommit = { ...adoptedFlow('minimal'), commit_required: false }
385
405
  const state = {
386
406
  stage: '交付',
387
407
  execution_version: 1,
@@ -396,7 +416,7 @@ test('P2: a flow may declare that its delivery mode needs no commit', () => {
396
416
  })
397
417
 
398
418
  test('P2: an unknown review_depth is a configuration error, not a silent downgrade', () => {
399
- const config = { ...resolveFlow('minimal', {}).config, review_depth: 'thorough' }
419
+ const config = { ...adoptedFlow('minimal'), review_depth: 'thorough' }
400
420
  const problems = validateWorkflow(config)
401
421
  assert.ok(problems.some(p => /review_depth/u.test(p)),
402
422
  `a typo must be reported; saw ${JSON.stringify(problems)}`)
package/.e2e-presets.mjs CHANGED
@@ -15,7 +15,22 @@ import assert from 'node:assert/strict'
15
15
  import { join, resolve } from 'node:path'
16
16
  import { registerDevTask } from './lib/dev-task.js'
17
17
  import { newTask } from './lib/engine.js'
18
- import { resolveFlow, FLOW_PRESETS } from './lib/workflows.js'
18
+ import { adoptRecommendation, resolveFlow, FLOW_PRESETS } from './lib/workflows.js'
19
+
20
+ /**
21
+ * The preset as a project would actually run it: the skeleton plus the shipped
22
+ * recommendation, adopted exactly the way a user adopts it. A test that asserts
23
+ * on bindings, commit rules or artifacts wants this, because those no longer come
24
+ * from the preset itself.
25
+ * @param {string} id - preset id.
26
+ * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
27
+ */
28
+ function adoptedFlow(id, extra) {
29
+ const base = adoptRecommendation(id)
30
+ if (base === undefined) throw new Error('unknown preset: ' + id)
31
+ return resolveFlow(id, { ...base, ...extra }).config
32
+ }
33
+
19
34
 
20
35
  const HASH = 'abcdef1234567890'
21
36
 
@@ -26,16 +41,18 @@ const HASH = 'abcdef1234567890'
26
41
  */
27
42
  async function runPreset(preset) {
28
43
  const cwd = resolve('e2e-project')
29
- const config = resolveFlow(preset, {}).config
44
+ const config = adoptedFlow(preset)
30
45
  const state = newTask({
31
46
  id: 'E2E-1', title: 'pipeline', branch: 'main', work_size: 'standard',
32
47
  risk_level: 'standard', flow: { flow: preset, version: FLOW_PRESETS[preset].version, config }, root: cwd,
33
48
  })
34
49
  const records = new Map([
35
50
  [join(cwd, '.dsh/task-E2E-1.json'), JSON.stringify(state)],
36
- // `create` reads the project's flow from here, so the preset under test must
37
- // be the project's declared flow rather than one passed to the tool.
38
- [join(cwd, '.dsh/eng.json'), JSON.stringify({ flow: preset })],
51
+ // `create` reads the project's config from here, so this writes what a real
52
+ // project has after adopting the shipped recommendation: the skeleton plus its
53
+ // recommended skills, commit rule and artifacts. Writing only `{flow}` would
54
+ // describe a project that adopted nothing — a different, equally valid case.
55
+ [join(cwd, '.dsh/eng.json'), JSON.stringify(adoptRecommendation(preset))],
39
56
  [join(cwd, 'src/a.js'), 'source'],
40
57
  ])
41
58
  const events = []
@@ -160,7 +177,25 @@ async function runPreset(preset) {
160
177
  }
161
178
  // The terminal stage's own obligations are checked before it can be left, and
162
179
  // completion is the operation that leaves it.
180
+ //
181
+ // A flow may declare its final requirements as `completion_guards` rather than on
182
+ // an edge, because a terminal stage has no edge to carry them — the agile flow's
183
+ // 审查 IS the review. They are satisfied here, at the last stage, rather than
184
+ // during the loop: they describe what completion means, not how a stage is entered.
163
185
  for (const binding of config.stage_bindings?.[current().stage]?.skills ?? []) load(binding.skill.name)
186
+ const terminalGuards = new Set(config.completion_guards ?? [])
187
+ if (terminalGuards.has('todos_done') && !current().items.some(item => item.status === 'done')) {
188
+ await call({ operation: 'items', items: [{ id: 'A', title: 'a', status: 'doing' }] })
189
+ await call({ operation: 'dispatch', item_id: 'A', description: 'implemented by a worker' })
190
+ await call({ operation: 'review_item', item_id: 'A', spec_outcome: 'pass', quality_outcome: 'pass' })
191
+ await call({ operation: 'items', items: [{ id: 'A', status: 'done' }] })
192
+ }
193
+ if (terminalGuards.has('verified')) {
194
+ await call({ operation: 'verify', command: 'npm test', description: 'run the suite' })
195
+ }
196
+ if (terminalGuards.has('review_passed')) {
197
+ await call({ operation: 'review', outcome: 'pass' })
198
+ }
164
199
  if (config.commit.checkpoints.includes(current().stage)) {
165
200
  const status = await call({ operation: 'status' })
166
201
  await call({
@@ -0,0 +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')
139
+ })