@godv61/dsh-task-engine 0.23.8 → 0.24.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.
@@ -0,0 +1,89 @@
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} 项未通过`)
89
+ process.exit(failures === 0 ? 0 : 1)
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Batch-1 reproductions for the 2026-09-23 external assessment.
3
+ *
4
+ * Each case asserts the CORRECT behaviour and is therefore expected to FAIL
5
+ * against 0.23.9, proving the defect exists before it is fixed. They reuse the
6
+ * existing simulated fixture pattern from .workflow-test.mjs: the real built
7
+ * `dev_task` tool and real engine functions run against an in-memory fs, so no
8
+ * assertion here is a source-string check.
9
+ *
10
+ * Not covered here: the real Harness Web pass, which the assessment also
11
+ * requires and which is reported separately.
12
+ */
13
+ import test from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import { join, resolve } from 'node:path'
16
+ import { registerDevTask } from './lib/dev-task.js'
17
+ import { newTask, todosBlockers, completionBlockers, validateWorkflow, formatResourceRef } from './lib/engine.js'
18
+ import { FLOW_PRESETS } from './lib/workflows.js'
19
+ import { resolveFlow } from './lib/workflows.js'
20
+
21
+ /** Build a fixture on one preset, mirroring .workflow-test.mjs's contract. */
22
+ function fixture(preset = 'standard', stage, extra = {}, services = {}) {
23
+ const cwd = resolve('test-project')
24
+ const config = resolveFlow(preset, {}).config
25
+ const state = newTask({
26
+ id: 'LIVE-1', title: 'assessment', branch: 'test', work_size: 'standard',
27
+ risk_level: 'standard', flow: { flow: preset, version: config.version ?? 1, config }, root: cwd,
28
+ })
29
+ Object.assign(state, {
30
+ stage, execution_version: 1, files: ['app.js'],
31
+ requirement_confirmed: true, solution_confirmed: true, ...extra,
32
+ })
33
+ const records = new Map([
34
+ [join(cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state)],
35
+ [join(cwd, 'app.js'), 'source'],
36
+ ])
37
+ const events = [], runs = []
38
+ const session = { id: 'test-session', header: { cwd }, snapshotEvents: () => events }
39
+ const policy = { mode: 'workspace-write', workspaceRoot: cwd, sessionId: session.id }
40
+ const abort = new AbortController()
41
+ let execute
42
+ const key = (path, options) => join(options?.cwd ?? cwd, path)
43
+ const fs = {
44
+ resolve: async (path, options) => ({ targetKey: key(path, options) }),
45
+ readText: async target => records.get(target.targetKey),
46
+ listDir: async () => [...records.keys()].filter(p => /task-.+\.json$/.test(p)).map(p => ({ name: p.split(/[\\/]/).at(-1) })),
47
+ lstat: async (path, options) => records.has(key(path, options)) ? { version: 'v' } : undefined,
48
+ writeText: async (target, content) => { records.set(target.targetKey, content) },
49
+ }
50
+ const ctx = {
51
+ fs,
52
+ tools: { register(tool) { execute = tool.execute; return () => {} } },
53
+ get(name) {
54
+ if (Object.hasOwn(services, name)) return services[name]
55
+ if (name === 'sandboxPolicy') return { resolve(request) { assert.equal(request.session, session); return policy } }
56
+ if (name === 'shell') return {
57
+ resolve(request) { runs.push(request); return request },
58
+ async run(request) {
59
+ const fail = request.command === 'fail'
60
+ return {
61
+ exitCode: fail ? 1 : 0, timedOut: false, aborted: false,
62
+ sandbox: { mode: 'workspace-write', denied: false },
63
+ stdout: { text: request.command.startsWith('git -c core.quotepath=false log')
64
+ ? 'abcdef1234567890\n【LIVE-1】【TASK】done\n\napp.js\n'
65
+ : 'checks passed' },
66
+ stderr: { text: '' },
67
+ }
68
+ },
69
+ }
70
+ return undefined
71
+ },
72
+ }
73
+ registerDevTask(ctx)
74
+ const exec = { agent: { session }, signal: abort.signal }
75
+ function load(name, success = true) {
76
+ const callId = `call-${events.length}`
77
+ events.push({ type: 'tool/call', data: { name: 'skill', callId, arguments: JSON.stringify({ name }) } })
78
+ events.push({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: callId, isError: !success }] } } })
79
+ }
80
+ return {
81
+ call: args => execute(JSON.parse(JSON.stringify({ task_id: 'LIVE-1', ...args })), exec),
82
+ load, runs, policy, exec, records, cwd,
83
+ state: () => JSON.parse(records.get(join(cwd, '.dsh/task-LIVE-1.json'))),
84
+ }
85
+ }
86
+
87
+ /** Run `verify` with a command, optionally failing. */
88
+ async function verify(f, command) {
89
+ return f.call({ operation: 'verify', command, description: 'checks' })
90
+ }
91
+
92
+ // ---------------------------------------------------------------- P1: stale verification
93
+
94
+ test('P1: a failed re-verification must block the commit at the review stage', async () => {
95
+ // Standard flow: 交付 -> 代码审核 requires `verified`, 代码审核 -> 完成 requires only
96
+ // review_passed. Re-verifying with a failing command flips `passed` to false, and
97
+ // evidenceBlockers guards its staleness check behind `passed &&`, so nothing reports it.
98
+ const f = fixture('standard', '代码审核')
99
+ await verify(f, 'pass') // establish a passing receipt
100
+ await f.call({ operation: 'record', artifact: 'review', fields: { conclusion: 'ok', issues: 'none' } })
101
+ await f.call({ operation: 'review', outcome: 'pass' })
102
+ await verify(f, 'fail') // re-verify: now failing
103
+ const status = JSON.parse(await f.call({ operation: 'status' }))
104
+ const passed = f.state().verification.passed
105
+ await assert.rejects(
106
+ () => f.call({ operation: 'commit' }),
107
+ /verif/i,
108
+ `commit must be refused while verification is failing (passed=${passed}, commit.allowed=${status.commit?.allowed})`,
109
+ )
110
+ })
111
+
112
+ test('P1: completion must re-check verification for a task that once crossed it', async () => {
113
+ const f = fixture('standard', '代码审核')
114
+ await verify(f, 'pass')
115
+ await f.call({ operation: 'record', artifact: 'review', fields: { conclusion: 'ok', issues: 'none' } })
116
+ await f.call({ operation: 'review', outcome: 'pass' })
117
+ await verify(f, 'fail')
118
+ const status = JSON.parse(await f.call({ operation: 'status' }))
119
+ const blockers = [...(status.evidence_blockers ?? []), ...(status.commit_blockers ?? [])]
120
+ assert.ok(
121
+ blockers.some(b => /verif/i.test(b)),
122
+ `status must report verification as blocking; saw ${JSON.stringify(blockers)}`,
123
+ )
124
+ })
125
+
126
+ // ---------------------------------------------------------------- P1: agile label contract
127
+
128
+ test('P1: the agile checkpoint label must satisfy the agile message pattern', async () => {
129
+ // commitCheckpoint returns 'TASK' at a checkpoint stage while agile's pattern only
130
+ // accepts \d+, so the label the model is handed cannot produce an acceptable message.
131
+ const f = fixture('agile', '交付', { items: [{ id: 'A', title: 'a', status: 'done' }] })
132
+ const status = JSON.parse(await f.call({ operation: 'status' }))
133
+ const label = status.commit?.label
134
+ const pattern = resolveFlow('agile', {}).config.commit.message_pattern
135
+ assert.ok(label !== undefined, 'a commit must be due at the agile checkpoint')
136
+ assert.match(
137
+ `【LIVE-1】【${label}】did a thing`,
138
+ new RegExp(pattern, 'u'),
139
+ `label ${JSON.stringify(label)} must satisfy the preset's own pattern ${JSON.stringify(pattern)}`,
140
+ )
141
+ })
142
+
143
+ // ---------------------------------------------------------------- verification scoping
144
+
145
+ test('P1: the verification requirement applies only after the flow reaches a verify gate', async () => {
146
+ // The fix must not overreach. `verified` guards the edge INTO a stage, so a task
147
+ // that has not crossed that edge has nothing to verify and must not be blocked
148
+ // by a requirement it has not yet met — otherwise the flow cannot even start.
149
+ const early = fixture('standard', '需求评审', {
150
+ artifacts: { requirement: { scope: 's', acceptance_criteria: 'a' } },
151
+ })
152
+ const earlyStatus = JSON.parse(await early.call({ operation: 'status' }))
153
+ assert.deepEqual(
154
+ earlyStatus.verification_blockers ?? [],
155
+ [],
156
+ 'a task before the verify gate must not be reported as failing verification',
157
+ )
158
+
159
+ // Past the gate it applies, and stays applied at every later stage.
160
+ for (const stage of ['代码审核', '完成']) {
161
+ const late = fixture('standard', stage)
162
+ const lateStatus = JSON.parse(await late.call({ operation: 'status' }))
163
+ assert.ok(
164
+ (lateStatus.verification_blockers ?? []).length > 0,
165
+ `stage ${stage} sits after the verify gate, so it must require verification`,
166
+ )
167
+ }
168
+ })
169
+
170
+ test('P1: a preset that never guards on verified must not demand verification', async () => {
171
+ // "Only what the user configured should be enforced." agile and minimal declare
172
+ // no `verified` edge, so the engine must not invent one.
173
+ for (const preset of ['agile', 'minimal']) {
174
+ const stage = preset === 'agile' ? '审查' : '交付'
175
+ const f = fixture(preset, stage)
176
+ const status = JSON.parse(await f.call({ operation: 'status' }))
177
+ assert.deepEqual(
178
+ status.verification_blockers ?? [],
179
+ [],
180
+ `${preset} declares no verification gate, so it must not report one`,
181
+ )
182
+ }
183
+ })
184
+
185
+ // ---------------------------------------------------------------- P1: agile artifact contract
186
+
187
+ test('P1: every skill bound by a preset must be able to record what it is told to record', async () => {
188
+ // agile binds code-review at 审查, and that skill instructs the model to
189
+ // `record artifact=review`, but agile declares only the `requirement` artifact.
190
+ const f = fixture('agile', '审查', {
191
+ verification: { passed: true, evidence: ['checks passed'] },
192
+ })
193
+ await assert.doesNotReject(
194
+ () => f.call({ operation: 'record', artifact: 'review', fields: { conclusion: 'ok', issues: 'none' } }),
195
+ 'the review artifact a bound skill is instructed to write must exist in the preset contract',
196
+ )
197
+ })
198
+
199
+ // ---------------------------------------------------------------- P2: minimal must be light
200
+
201
+ test('P2: the minimal preset reviews each item once, and still reviews it', () => {
202
+ // This earlier asserted on status.skill_blockers/commit_blockers, which never
203
+ // contain todosBlockers output, so it passed against a completely unfixed
204
+ // engine. Call the function under test directly instead.
205
+ const minimal = resolveFlow('minimal', {}).config
206
+ const standard = resolveFlow('standard', {}).config
207
+ const specPassQualityFail = {
208
+ items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
209
+ }
210
+ assert.deepEqual(todosBlockers(specPassQualityFail, minimal), [],
211
+ 'minimal asks for one verdict, so a missing quality verdict must not block')
212
+ assert.ok(todosBlockers(specPassQualityFail, standard).length > 0,
213
+ 'the full flow still requires both verdicts')
214
+
215
+ // "Lighter" must mean fewer verdicts, never fewer checks.
216
+ const noReview = { items: [{ id: 'A', title: 'a', status: 'done' }] }
217
+ assert.ok(todosBlockers(noReview, minimal).length > 0,
218
+ 'an unreviewed item must still block the lightest flow')
219
+ const specFail = {
220
+ items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'fail' }, quality: { outcome: 'pass' } } }],
221
+ }
222
+ assert.ok(todosBlockers(specFail, minimal).length > 0,
223
+ 'a failing verdict must still block the lightest flow')
224
+ })
225
+
226
+ test('P2: the three presets must not share one identical review burden', () => {
227
+ // The presets differ by task complexity, so their audit weight must differ too.
228
+ // Before this change all three returned byte-identical blockers for one state.
229
+ const state = {
230
+ items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'fail' } } }],
231
+ }
232
+ const byDepth = ['standard', 'agile', 'minimal'].map(id =>
233
+ todosBlockers(state, resolveFlow(id, {}).config).length)
234
+ assert.ok(
235
+ byDepth[2] < byDepth[0],
236
+ `the fast-change flow must audit less per item than the full one; got ${JSON.stringify(byDepth)}`,
237
+ )
238
+ })
239
+
240
+ // ---------------------------------------------------------------- P2: missing rule must surface
241
+
242
+ test('P2: a rule a skill declares but that resolves nowhere must be reported, not skipped', async () => {
243
+ // Resolution used to simply omit a name it could not find, so a binding silently
244
+ // stopped being disclosed. Rules now hang off the skill, so the reference to
245
+ // check lives on the skill binding.
246
+ const f = fixture('standard', '开发', {}, {})
247
+ const state = f.state()
248
+ state.flow.config.stage_bindings = {
249
+ 开发: {
250
+ skills: [
251
+ { skill: { source: 'bundled', name: 'code-implement' }, rules: [{ source: 'bundled', name: 'no-such-rule-anywhere' }] },
252
+ ],
253
+ },
254
+ }
255
+ f.records.set(join(f.cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state))
256
+ const status = JSON.parse(await f.call({ operation: 'status' }))
257
+ const text = JSON.stringify(status)
258
+ assert.match(
259
+ text,
260
+ /no-such-rule-anywhere/u,
261
+ 'a rule that resolves nowhere must appear in status rather than vanishing',
262
+ )
263
+ })
264
+
265
+ // ---------------------------------------------------------------- batch 3: rules belong to skills
266
+
267
+ test('batch3: a stage selects skills only, and a skill carries its own complete rule list', () => {
268
+ // The whole point of the model: opening a skill tells you every rule it runs
269
+ // under, and binding it anywhere does not add or override any rule.
270
+ const config = resolveFlow('standard', {}).config
271
+ for (const [stage, binding] of Object.entries(config.stage_bindings ?? {})) {
272
+ assert.ok(
273
+ !('rules' in binding) || binding.rules === undefined,
274
+ `stage ${stage} must not carry a stage-level rule list`,
275
+ )
276
+ for (const entry of binding.skills ?? []) {
277
+ assert.ok(Array.isArray(entry.rules),
278
+ `skill ${entry.skill.name} on ${stage} must declare its own rules array`)
279
+ assert.ok(entry.skill.source === 'bundled',
280
+ `preset bindings are bundled resources; ${entry.skill.name} declares ${entry.skill.source}`)
281
+ }
282
+ }
283
+ })
284
+
285
+ test('batch3: the same skill carries identical rules wherever it is bound', () => {
286
+ // "Same skill, same rules" is the acceptance criterion: copying the skill is how
287
+ // a user gets a different rule set, so the same reference must never differ.
288
+ const byRef = new Map()
289
+ for (const [id, preset] of Object.entries(FLOW_PRESETS)) {
290
+ for (const [stage, binding] of Object.entries(preset.config.stage_bindings ?? {})) {
291
+ for (const entry of binding.skills ?? []) {
292
+ const key = formatResourceRef(entry.skill)
293
+ const rules = entry.rules.map(formatResourceRef).sort().join(',')
294
+ if (byRef.has(key)) {
295
+ assert.equal(byRef.get(key).rules, rules,
296
+ `${key} carries different rules in ${byRef.get(key).where} and ${id}/${stage}`)
297
+ } else {
298
+ byRef.set(key, { rules, where: `${id}/${stage}` })
299
+ }
300
+ }
301
+ }
302
+ }
303
+ })
304
+
305
+ test('batch3: a rule shared by two skills is one resource, referenced twice', () => {
306
+ // Reuse must not duplicate the rule body: two skills referencing one rule point
307
+ // at the same source-qualified reference.
308
+ const config = resolveFlow('standard', {}).config
309
+ const review = config.stage_bindings['代码审核'].skills
310
+ const reviewRules = review.find(entry => entry.skill.name === 'code-review').rules.map(formatResourceRef)
311
+ const implementRules = config.stage_bindings['开发'].skills
312
+ .find(entry => entry.skill.name === 'code-implement').rules.map(formatResourceRef)
313
+ const shared = reviewRules.filter(rule => implementRules.includes(rule))
314
+ assert.ok(shared.length > 0,
315
+ 'coding-conventions and security-redlines apply to both implementing and reviewing code, so at least one rule must be shared by reference')
316
+ assert.ok(shared.includes('bundled:security-redlines'),
317
+ `security-redlines is the clear shared case; saw ${JSON.stringify(shared)}`)
318
+ })
319
+
320
+ test('batch3: legacy stage-level rules stay in force and are named as unassigned', async () => {
321
+ // A pre-migration config put rules on the stage. Assigning them silently would
322
+ // invent an answer the config never had; dropping them would lose a constraint.
323
+ const f = fixture('standard', '开发', {}, {})
324
+ const state = f.state()
325
+ state.flow.config.stage_bindings = { 开发: { skills: [], legacy_rules: ['security-redlines'] } }
326
+ f.records.set(join(f.cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state))
327
+ const status = JSON.parse(await f.call({ operation: 'status' }))
328
+ assert.ok(
329
+ (status.unassigned_legacy_rules ?? []).includes('security-redlines'),
330
+ `the legacy rule must be reported as unassigned; saw ${JSON.stringify(status.unassigned_legacy_rules)}`,
331
+ )
332
+ // Still enforced: it resolves and appears among the rules in force.
333
+ assert.ok(
334
+ (status.rules ?? []).some(rule => rule.name === 'security-redlines'),
335
+ `a legacy rule must keep applying; saw ${JSON.stringify(status.rules)}`,
336
+ )
337
+ })
338
+
339
+ // ---------------------------------------------------------------- batch 2: completion
340
+
341
+ test('P2: reaching the last stage is not completion for any preset', () => {
342
+ // minimal's final stage is also its commit checkpoint, and the "commit before
343
+ // leaving a checkpoint" rule only fires on the way OUT of a stage — so that
344
+ // flow could stand on its last stage with no delivery record at all. Completion
345
+ // is now its own checked event, shared by all three presets.
346
+ for (const [preset, stage] of [['standard', '完成'], ['agile', '审查'], ['minimal', '交付']]) {
347
+ const config = resolveFlow(preset, {}).config
348
+ const state = {
349
+ stage,
350
+ execution_version: 1,
351
+ items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
352
+ verification: { passed: true, evidence: [] },
353
+ commits: [],
354
+ }
355
+ assert.ok(
356
+ completionBlockers(state, config).length > 0,
357
+ `${preset} must not complete without its delivery record`,
358
+ )
359
+ const committed = { ...state, commits: [{ label: 'TASK', hash: 'abc1234' }] }
360
+ assert.deepEqual(
361
+ completionBlockers(committed, config), [],
362
+ `${preset} must complete once the delivery record exists`,
363
+ )
364
+ }
365
+ })
366
+
367
+ test('P2: completion refuses a task that has not reached a final stage', () => {
368
+ const config = resolveFlow('standard', {}).config
369
+ const state = {
370
+ stage: '开发',
371
+ execution_version: 1,
372
+ items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
373
+ verification: { passed: true, evidence: [] },
374
+ commits: [{ label: 'TASK', hash: 'abc1234' }],
375
+ }
376
+ const blockers = completionBlockers(state, config)
377
+ assert.ok(blockers.some(b => /not a final stage/u.test(b)),
378
+ `an unfinished flow must not be completable; saw ${JSON.stringify(blockers)}`)
379
+ })
380
+
381
+ test('P2: a flow may declare that its delivery mode needs no commit', () => {
382
+ // A Git commit is one delivery mode, not a universal finish line: a non-code
383
+ // task or a project outside version control has nothing to commit.
384
+ const withoutCommit = { ...resolveFlow('minimal', {}).config, commit_required: false }
385
+ const state = {
386
+ stage: '交付',
387
+ execution_version: 1,
388
+ items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' } } }],
389
+ verification: { passed: true, evidence: [] },
390
+ commits: [],
391
+ }
392
+ assert.deepEqual(
393
+ completionBlockers(state, withoutCommit), [],
394
+ 'a flow that does not require a commit must be completable without one',
395
+ )
396
+ })
397
+
398
+ test('P2: an unknown review_depth is a configuration error, not a silent downgrade', () => {
399
+ const config = { ...resolveFlow('minimal', {}).config, review_depth: 'thorough' }
400
+ const problems = validateWorkflow(config)
401
+ assert.ok(problems.some(p => /review_depth/u.test(p)),
402
+ `a typo must be reported; saw ${JSON.stringify(problems)}`)
403
+ })