@godv61/dsh-task-engine 0.23.9 → 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/.p0-test.mjs CHANGED
@@ -6,12 +6,29 @@ import { readFileSync } from 'node:fs'
6
6
  import { registerDevTask } from './lib/dev-task.js'
7
7
  import { assertAdvance, checkFileScope, newTask, taskIdFromMessage, validateWorkflow } from './lib/engine.js'
8
8
  import {
9
+
10
+
9
11
  FLOW_PRESETS,
10
12
  HIGH_RISK_REQUIRED_CAPABILITIES,
11
13
  flowSatisfies,
12
14
  resolveFlow,
15
+ adoptRecommendation,
13
16
  } from './lib/workflows.js'
14
17
 
18
+ /**
19
+ * The preset as a project would actually run it: the skeleton plus the shipped
20
+ * recommendation, adopted exactly the way a user adopts it. A test that asserts
21
+ * on bindings, commit rules or artifacts wants this, because those no longer come
22
+ * from the preset itself.
23
+ * @param {string} id - preset id.
24
+ * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
25
+ */
26
+ function adoptedFlow(id, extra) {
27
+ const base = adoptRecommendation(id)
28
+ if (base === undefined) throw new Error('unknown preset: ' + id)
29
+ return resolveFlow(id, { ...base, ...extra }).config
30
+ }
31
+
15
32
  let passed = 0
16
33
  function assert(cond, msg) {
17
34
  if (!cond) {
@@ -101,27 +118,64 @@ function sessionExec(cwd) {
101
118
  assert(flowSatisfies('standard', HIGH_RISK_REQUIRED_CAPABILITIES), 'standard satisfies high-risk capabilities')
102
119
  assert(!flowSatisfies('agile', HIGH_RISK_REQUIRED_CAPABILITIES), 'agile lacks a high-risk capability')
103
120
  assert(!flowSatisfies('minimal', HIGH_RISK_REQUIRED_CAPABILITIES), 'minimal lacks a high-risk capability')
104
- assert(FLOW_PRESETS.standard.version === 2, 'standard preset carries the review-before-commit version')
121
+ // Version 3 is the skeleton/recommendation split: the preset now carries only
122
+ // state control, and its former bindings, commit text and artifacts are a
123
+ // separate recommendation a user adopts. The version number is what makes an
124
+ // already-created task keep its frozen config instead of silently changing.
125
+ assert(FLOW_PRESETS.standard.version === 3, 'standard preset carries the skeleton/recommendation version')
126
+ // Versions are per-preset because they record per-preset changes: agile moved
127
+ // again when 审查 gained a completion guard, and a task created before that
128
+ // keeps its frozen version and behaves exactly as it did.
129
+ assert(FLOW_PRESETS.standard.version === 3, 'standard carries the skeleton/recommendation version')
130
+ assert(FLOW_PRESETS.minimal.version === 3, 'minimal carries the skeleton/recommendation version')
131
+ assert(FLOW_PRESETS.agile.version === 4, 'agile moved again for the completion guard')
105
132
 
106
133
  // ── 2. unknown flow fails closed ───────────────────────────────────────────
134
+ // `resolveFlow` is called directly here: this test is about the fail-closed
135
+ // result, and the adoption helper throws instead of returning one.
107
136
  const unknown = resolveFlow('nope')
108
137
  assert(unknown.ok === false && unknown.code === 'UNKNOWN_FLOW', 'unknown flow returns UNKNOWN_FLOW')
109
138
  assert(unknown.knownFlows.includes('standard'), 'known flow list is surfaced')
110
139
 
111
- // ── 3. core bindings cannot be cancelled by an override ────────────────────
112
- const merged = resolveFlow('standard', { stage_bindings: { '开发': { rules: [] } } })
113
- assert(merged.ok && merged.config.stage_bindings['开发'].rules.includes('coding-conventions'),
114
- 'emptying a stage override keeps the core rule')
140
+ // ── 3. the user's bindings are taken as given, not merged with a preset's ────
141
+ // The old invariant here was "a preset's core skill survives any override", which
142
+ // meant a project could never drop a shipped skill and a project's own rule list
143
+ // for that skill was discarded because the skill reference already existed. The
144
+ // contract is now the opposite: what the user configured IS the configuration,
145
+ // and the preset offers its setup only through explicit adoption.
146
+ const bare = resolveFlow('standard', { flow: 'standard' })
147
+ assert(bare.ok && Object.keys(bare.config.stage_bindings ?? {}).length === 0,
148
+ 'the preset skeleton carries no bindings of its own')
149
+
150
+ const emptied = resolveFlow('standard', { flow: 'standard', stage_bindings: { '开发': { skills: [] } } })
151
+ assert(emptied.ok && (emptied.config.stage_bindings['开发'].skills ?? []).length === 0,
152
+ 'a project that binds nothing to a stage keeps it empty; nothing is merged back in')
153
+
154
+ const adopted = adoptedFlow('standard')
155
+ assert(adopted.stage_bindings['开发'].skills.some(entry => entry.skill.name === 'code-implement'),
156
+ 'adopting the recommendation is what supplies the shipped skills')
157
+
158
+ // And a user who gives a shipped skill their own rule list gets that list — the
159
+ // defect this replaces was that the entry was skipped as a duplicate reference.
160
+ const ownRules = resolveFlow('standard', {
161
+ flow: 'standard',
162
+ stage_bindings: {
163
+ '开发': { skills: [{ skill: { source: 'bundled', name: 'code-implement' }, rules: [{ source: 'project', name: 'only-mine' }] }] },
164
+ },
165
+ }).config
166
+ const implementBinding = ownRules.stage_bindings['开发'].skills.find(entry => entry.skill.name === 'code-implement')
167
+ assert(implementBinding.rules.length === 1 && implementBinding.rules[0].name === 'only-mine',
168
+ 'a project rule list for a shipped skill is used as given, not discarded as a duplicate')
115
169
 
116
170
  // ── 4. newTask freezes the snapshot ────────────────────────────────────────
117
- const snapshot = { flow: 'standard', version: 1, config: FLOW_PRESETS.standard.config }
171
+ const snapshot = { flow: 'standard', version: 1, config: adoptedFlow('standard') }
118
172
  const state = newTask({ id: 'T1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
119
173
  assert(state.flow?.flow === 'standard' && state.flow.version === 1, 'newTask stores the frozen snapshot')
120
174
  assert(state.stage === '需求评审', 'newTask starts at the frozen start stage')
121
175
 
122
176
  // ── 5. high_risk create on minimal is rejected ─────────────────────────────
123
177
  await assertThrows(
124
- () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) }))
178
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('minimal')) }))
125
179
  .then(exe => exe({ operation: 'create', task_id: 'T2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)),
126
180
  'high_risk',
127
181
  'high_risk on minimal is rejected',
@@ -129,13 +183,13 @@ await assertThrows(
129
183
 
130
184
  // ── 6. a task keeps its frozen snapshot after eng.json changes ─────────────
131
185
  {
132
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
186
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
133
187
  const exe = await registered(fs)
134
188
  const created = await exe({ operation: 'create', task_id: 'T3', title: 'x', branch: 'main' }, EXEC)
135
189
  assert(created.includes('需求评审'), 'standard create starts at 需求评审')
136
- fs._files.set('.dsh/eng.json', JSON.stringify({ flow: 'minimal' }))
190
+ fs._files.set('.dsh/eng.json', JSON.stringify(adoptRecommendation('minimal')))
137
191
  const status = JSON.parse(await exe({ operation: 'status', task_id: 'T3' }, EXEC))
138
- assert(status.flow?.flow === 'standard' && status.flow.version === 2, 'status re-reads the frozen snapshot')
192
+ assert(status.flow?.flow === 'standard' && status.flow.version === 3, 'status re-reads the frozen snapshot')
139
193
  assert(Array.isArray(status.legal_next) && status.legal_next.includes('设计'),
140
194
  'gates come from the frozen standard flow, not the live minimal flow')
141
195
  }
@@ -206,22 +260,59 @@ await assertThrows(
206
260
  )
207
261
  }
208
262
 
209
- // ── 11. a project cannot shadow a bundled core rule ────────────────────────
210
- {
211
- const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
212
- assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
213
- const fs = makeFs({
214
- '.dsh/eng.json': JSON.stringify({ flow: 'standard' }),
215
- '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
216
- })
217
- const exe = await registered(fs)
218
- await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
219
- const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
220
- const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
221
- assert(sec !== undefined, 'security-redlines is disclosed at the start stage')
222
- assert(!sec.content.includes('前端校验即可'), 'a project shadow of a core rule is ignored')
223
- assert(sec.content.includes('服务端'), 'the bundled core rule body wins over a shadow')
224
- }
263
+ // ── 11. a bound rule resolves from the layer the reference names ────────────
264
+ // Rules used to be looked up by bare name through a precedence chain, so a
265
+ // project file named like a bundled one silently replaced the shipped body.
266
+ // A reference now names its layer (`bundled:` / `project:` / `user:`), so there
267
+ // is no chain to walk and no shadowing to defend against: the same name in two
268
+ // layers is two distinct resources, and the binding says which one it means.
269
+ {
270
+ const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
271
+ assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
272
+ const fs = makeFs({
273
+ // A project file with the same NAME but a different body. Under the old
274
+ // bare-name lookup this would have replaced the bundled rule silently.
275
+ '.dsh/eng.json': JSON.stringify({
276
+ flow: 'standard',
277
+ stage_bindings: {
278
+ '需求评审': { skills: [{
279
+ skill: { source: 'bundled', name: 'requirement-analysis' },
280
+ rules: [{ source: 'project', name: 'security-redlines' }],
281
+ }] },
282
+ },
283
+ }),
284
+ '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
285
+ })
286
+ const exe = await registered(fs)
287
+ await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
288
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
289
+ const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
290
+ assert(sec !== undefined, 'the bound project rule is disclosed at the start stage')
291
+ assert(sec.source === 'project', 'the disclosed rule reports the layer it was resolved from')
292
+ assert(sec.content.includes('前端校验即可'), 'the PROJECT body is what gets disclosed, because the reference names the project layer')
293
+ }
294
+
295
+ // ── 11b. a bundled reference keeps resolving the bundled body ───────────────
296
+ {
297
+ const fs = makeFs({
298
+ '.dsh/eng.json': JSON.stringify({
299
+ flow: 'standard',
300
+ stage_bindings: {
301
+ '需求评审': { skills: [{
302
+ skill: { source: 'bundled', name: 'requirement-analysis' },
303
+ rules: [{ source: 'bundled', name: 'security-redlines' }],
304
+ }] },
305
+ },
306
+ }),
307
+ '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
308
+ })
309
+ const exe = await registered(fs)
310
+ await exe({ operation: 'create', task_id: 'T10', title: 'x', branch: 'main' }, EXEC)
311
+ const status = JSON.parse(await exe({ operation: 'status', task_id: 'T10' }, EXEC))
312
+ const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
313
+ assert(sec !== undefined && sec.source === 'bundled', 'a bundled reference resolves to the bundled layer')
314
+ assert(sec.content.includes('服务端'), 'the bundled body is used, untouched by the same-named project file')
315
+ }
225
316
 
226
317
  // ── 12. session cwd drives every relative path (init regression) ──────────
227
318
  {
@@ -239,7 +330,7 @@ await assertThrows(
239
330
  // ── 13. create writes the task record into the session workspace ──────────
240
331
  {
241
332
  const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
242
- const fs = makeFs({ [`${cwd}/.dsh/eng.json`]: JSON.stringify({ flow: 'standard' }) })
333
+ const fs = makeFs({ [`${cwd}/.dsh/eng.json`]: JSON.stringify(adoptRecommendation('standard')) })
243
334
  const exe = await registered(fs)
244
335
  await exe({ operation: 'create', task_id: 'T12', title: 'x', branch: 'main' }, sessionExec(cwd))
245
336
  assert(fs._files.get(`${cwd}/.dsh/task-T12.json`) !== undefined, 'task record lands in the session workspace')
@@ -248,7 +339,7 @@ await assertThrows(
248
339
 
249
340
  // ── 14. K: artifact ids must be unique across ALL stages ────────────────────
250
341
  {
251
- const withDup = { ...FLOW_PRESETS.standard.config, artifacts: [
342
+ const withDup = { ...adoptedFlow('standard'), artifacts: [
252
343
  { stage: '需求评审', id: 'doc', name: '需求', fields: ['scope'] },
253
344
  { stage: '设计', id: 'doc', name: '设计', fields: ['approach'] },
254
345
  ] }
@@ -259,7 +350,7 @@ await assertThrows(
259
350
 
260
351
  // ── 15. K: record rejects an artifact owned by another stage ─────────────────
261
352
  {
262
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
353
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
263
354
  const exe = await registered(fs)
264
355
  await exe({ operation: 'create', task_id: 'K1', title: 'x', branch: 'main' }, EXEC)
265
356
  await assertThrows(
@@ -271,7 +362,7 @@ await assertThrows(
271
362
 
272
363
  // ── 16. L/4.2: high-risk verification requires a REAL command receipt ────────
273
364
  {
274
- const cfg = FLOW_PRESETS.standard.config
365
+ const cfg = adoptedFlow('standard')
275
366
  const receipt = overrides => ({
276
367
  command: 'npm test', exit_code: 0, timed_out: false, aborted: false,
277
368
  started_at: '2026-01-01T00:00:00Z', finished_at: '2026-01-01T00:00:01Z',
@@ -296,20 +387,20 @@ await assertThrows(
296
387
 
297
388
  // ── 17. J: task id extracted from the summary (the hook uses it, not a guess) ─
298
389
  {
299
- const cfg = FLOW_PRESETS.standard.config
390
+ const cfg = adoptedFlow('standard')
300
391
  assert(taskIdFromMessage('【GREET-001】【TASK】实现登录', cfg) === 'GREET-001', 'standard summary yields its task id')
301
392
  assert(taskIdFromMessage('【GREET-001】【T1】实现登录', cfg) === 'GREET-001', 'item-label summary yields its task id')
302
393
  assert(taskIdFromMessage('实现登录', cfg) === undefined, 'non-matching summary yields no task id')
303
- assert(taskIdFromMessage('随意', FLOW_PRESETS.minimal.config) === undefined, 'pattern-less flow yields no task id')
394
+ assert(taskIdFromMessage('随意', adoptedFlow('minimal')) === undefined, 'pattern-less flow yields no task id')
304
395
  }
305
396
 
306
397
  // ── 18. N: engine bookkeeping files are exempt from file scope ───────────────
307
398
  {
308
399
  const s = newTask({ id: 'N1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
309
400
  s.files = ['src/a.ts']
310
- assert(checkFileScope(s, ['src/a.ts', '.dsh/task-N1.json', '.dsh/eng.json'], FLOW_PRESETS.standard.config).ok,
401
+ assert(checkFileScope(s, ['src/a.ts', '.dsh/task-N1.json', '.dsh/eng.json'], adoptedFlow('standard')).ok,
311
402
  'task record and eng.json do not violate file scope')
312
- assert(!checkFileScope(s, ['src/b.ts'], FLOW_PRESETS.standard.config).ok,
403
+ assert(!checkFileScope(s, ['src/b.ts'], adoptedFlow('standard')).ok,
313
404
  'an out-of-scope code file is still flagged')
314
405
  }
315
406
 
@@ -440,7 +531,7 @@ function memProbe(files) {
440
531
  // ── 23. 0.22-A: snapshot hash integrity ────────────────────────────────────
441
532
  {
442
533
  const { hashConfig } = await import('./lib/snapshot.js')
443
- const config = FLOW_PRESETS.standard.config
534
+ const config = adoptedFlow('standard')
444
535
  const h1 = hashConfig(config)
445
536
  const h2 = hashConfig(JSON.parse(JSON.stringify(config)))
446
537
  assert(h1 === h2 && /^[0-9a-f]{64}$/.test(h1), 'hashConfig is a deterministic sha256')
@@ -449,7 +540,7 @@ function memProbe(files) {
449
540
  assert(hashConfig(tampered) !== h1, 'editing the frozen config changes the hash')
450
541
  }
451
542
  {
452
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
543
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
453
544
  const exe = await registered(fs)
454
545
  await exe({ operation: 'create', task_id: 'HASH-1', title: 'x', branch: 'main' }, EXEC)
455
546
  const record = JSON.parse(fs._files.get('.dsh/task-HASH-1.json'))
@@ -484,7 +575,7 @@ function memProbe(files) {
484
575
 
485
576
  // ── 25. 0.22-B: risk downgrade approval + bindings fingerprint ─────────────
486
577
  {
487
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
578
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
488
579
  const exe = await registered(fs)
489
580
  await exe({ operation: 'create', task_id: 'RISK-1', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
490
581
  await assertThrows(
@@ -497,7 +588,7 @@ function memProbe(files) {
497
588
  }
498
589
  {
499
590
  const approval = { async request() { return 'allowed-once' } }
500
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
591
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
501
592
  const exe = await registered(fs, approval)
502
593
  await exe({ operation: 'create', task_id: 'RISK-2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
503
594
  assert((await exe({ operation: 'set_risk', task_id: 'RISK-2', risk_level: 'standard' }, EXEC)).includes('risk_level set to standard'),
@@ -507,7 +598,7 @@ function memProbe(files) {
507
598
  'downgrade is recorded in the audit trail')
508
599
  }
509
600
  {
510
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
601
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
511
602
  const exe = await registered(fs)
512
603
  await exe({ operation: 'create', task_id: 'FP-1', title: 'x', branch: 'main' }, EXEC)
513
604
  const status = JSON.parse(await exe({ operation: 'status', task_id: 'FP-1' }, EXEC))
@@ -529,7 +620,7 @@ function memProbe(files) {
529
620
  )
530
621
  }
531
622
  {
532
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
623
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')), 'package.json': '{}' })
533
624
  const exe = await registered(fs)
534
625
  await exe({ operation: 'create', task_id: 'V-2', title: 'x', branch: 'main' }, EXEC)
535
626
  await assertThrows(
@@ -539,7 +630,7 @@ function memProbe(files) {
539
630
  )
540
631
  }
541
632
  {
542
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
633
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
543
634
  const exe = await registered(fs)
544
635
  await exe({ operation: 'create', task_id: 'V-3', title: 'x', branch: 'main' }, EXEC)
545
636
  await assertThrows(() => exe({ operation: 'verify', task_id: 'V-3', passed: true }, EXEC),
@@ -551,7 +642,7 @@ function memProbe(files) {
551
642
  'legacy tasks retain their verification contract')
552
643
  }
553
644
  {
554
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
645
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
555
646
  const exe = await registered(fs)
556
647
  await exe({ operation: 'create', task_id: 'A-1', title: 'x', branch: 'main' }, EXEC)
557
648
  await exe({ operation: 'create', task_id: 'B-2', title: 'y', branch: 'main' }, EXEC)
@@ -587,7 +678,7 @@ function memProbe(files) {
587
678
 
588
679
  // ── 29. dev_task writes carry the per-call sandbox policy ───────────────────
589
680
  {
590
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
681
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
591
682
  let seenPolicy
592
683
  const origWrite = fs.writeText
593
684
  fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
@@ -609,7 +700,7 @@ function memProbe(files) {
609
700
 
610
701
  // ── 30. 0.22.x: sandbox escalation requires a human one-shot approval ───────
611
702
  {
612
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
703
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
613
704
  let seenPolicy
614
705
  const origWrite = fs.writeText
615
706
  fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
@@ -627,7 +718,7 @@ function memProbe(files) {
627
718
  }
628
719
 
629
720
  {
630
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
721
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
631
722
  const exe = await registered(fs) // no approval service
632
723
  await assertThrows(
633
724
  () => exe({
@@ -641,7 +732,7 @@ function memProbe(files) {
641
732
 
642
733
  // ── 31. 0.22.x: set_risk cannot raise risk past the flow's capabilities ─────
643
734
  {
644
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) })
735
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('minimal')) })
645
736
  const exe = await registered(fs)
646
737
  await exe({ operation: 'create', task_id: 'SR-1', title: 'x', branch: 'main' }, EXEC)
647
738
  await assertThrows(
@@ -651,7 +742,7 @@ function memProbe(files) {
651
742
  )
652
743
  }
653
744
  {
654
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
745
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
655
746
  const exe = await registered(fs)
656
747
  await exe({ operation: 'create', task_id: 'SR-2', title: 'x', branch: 'main' }, EXEC)
657
748
  assert((await exe({ operation: 'set_risk', task_id: 'SR-2', risk_level: 'high_risk' }, EXEC)).includes('high_risk'),
@@ -667,7 +758,7 @@ function memProbe(files) {
667
758
 
668
759
  // ── 33. 0.23: verify runs in the task's recorded project root ──────────────
669
760
  {
670
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
761
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')), 'package.json': '{}' })
671
762
  const seenWorkdirs = []
672
763
  const shell = {
673
764
  resolve(request) { return { command: request.command, workdir: request.workdir ?? '', timeoutMs: request.timeoutMs } },
@@ -687,7 +778,7 @@ function memProbe(files) {
687
778
 
688
779
  // ── 34. 0.23: revision increments on every write (CAS foundation) ───────────
689
780
  {
690
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
781
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
691
782
  const exe = await registered(fs)
692
783
  await exe({ operation: 'create', task_id: 'CAS-1', title: 'x', branch: 'main' }, EXEC)
693
784
  const r1 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
@@ -717,7 +808,7 @@ function memProbe(files) {
717
808
 
718
809
  // A second writer between content read and write must not be silently overwritten.
719
810
  {
720
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
811
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
721
812
  const exe = await registered(fs)
722
813
  await exe({ operation: 'create', task_id: 'CAS-RACE', title: 'race', branch: 'main' }, EXEC)
723
814
  const originalRead = fs.readText.bind(fs)
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Rework semantics: going backwards must invalidate exactly the superseded
3
+ * conclusions, no more and no less.
4
+ *
5
+ * The graph has only forward edges while the work does not, and before this the
6
+ * only way back was hand-editing the record — which left a confirmation,
7
+ * verification receipt or review verdict describing a superseded tree still
8
+ * reading as passing. The two halves of that are both tested: what must fall, and
9
+ * what must survive so work is not redone for no reason.
10
+ */
11
+ import test from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import { applyRevision, invalidatedBy, completionBlockers } from './lib/engine.js'
14
+ import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
15
+
16
+ /**
17
+ * The preset as a project would actually run it: the skeleton plus the shipped
18
+ * recommendation, adopted exactly the way a user adopts it. A test that asserts
19
+ * on bindings, commit rules or artifacts wants this, because those no longer come
20
+ * from the preset itself.
21
+ * @param {string} id - preset id.
22
+ * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
23
+ */
24
+ function adoptedFlow(id, extra) {
25
+ const base = adoptRecommendation(id)
26
+ if (base === undefined) throw new Error('unknown preset: ' + id)
27
+ return resolveFlow(id, { ...base, ...extra }).config
28
+ }
29
+
30
+
31
+ /** A task carrying every conclusion, so a rework has something to invalidate. */
32
+ function loadedTask() {
33
+ return {
34
+ schema: 1,
35
+ id: 'R-1',
36
+ title: 'rework',
37
+ branch: 'main',
38
+ work_size: 'standard',
39
+ risk_level: 'standard',
40
+ stage: '代码审核',
41
+ executed: undefined,
42
+ requirement_confirmed: true,
43
+ solution_confirmed: true,
44
+ items: [{ id: 'A', title: 'a', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
45
+ verification: { passed: true, evidence: ['checks'], receipt: { exit_code: 0, timed_out: false, aborted: false } },
46
+ review: { outcome: 'pass' },
47
+ artifacts: {},
48
+ files: ['a.js'],
49
+ commits: [{ label: 'TASK', hash: 'abc1234' }],
50
+ execution_version: 1,
51
+ }
52
+ }
53
+
54
+ const rework = (kind, to) => ({ kind, reason: 'why', to, from: '代码审核', at: '2026-09-23T00:00:00.000Z', invalidated: invalidatedBy(kind) })
55
+
56
+ test('rework: the three kinds declare distinct scopes rather than one blanket rule', () => {
57
+ const requirement = invalidatedBy('requirement')
58
+ const solution = invalidatedBy('solution')
59
+ const defect = invalidatedBy('defect')
60
+ assert.ok(requirement.includes('requirement_confirmation'),
61
+ 'a changed requirement must invalidate its own confirmation')
62
+ assert.ok(!solution.includes('requirement_confirmation'),
63
+ 'a changed approach leaves the agreed requirement standing')
64
+ assert.ok(!defect.includes('requirement_confirmation') && !defect.includes('solution_confirmation'),
65
+ 'a defect in the work does not un-agree what was agreed')
66
+ assert.ok(defect.includes('verification') && defect.includes('review'),
67
+ 'a defect invalidates the evidence that the old implementation was correct')
68
+ })
69
+
70
+ test('rework: a changed requirement clears confirmations and downstream evidence', () => {
71
+ const state = loadedTask()
72
+ const outcome = applyRevision(state, rework('requirement', '需求评审'))
73
+ assert.equal(state.stage, '需求评审')
74
+ assert.equal(state.requirement_confirmed, false)
75
+ assert.equal(state.solution_confirmed, false)
76
+ assert.equal(state.verification.passed, false)
77
+ assert.equal(state.review.outcome, 'pending')
78
+ assert.equal(state.items[0].review, undefined)
79
+ assert.deepEqual(state.commits, [])
80
+ assert.equal(state.completed, undefined)
81
+ assert.ok(outcome.invalidated.includes('requirement_confirmation'))
82
+ })
83
+
84
+ test('rework: fixing a defect keeps the agreed requirement and solution', () => {
85
+ // The point of distinguishing kinds: a defect must not force the requirement to
86
+ // be re-agreed, which would make the lightest flows unusable for ordinary fixes.
87
+ const state = loadedTask()
88
+ applyRevision(state, rework('defect', '开发'))
89
+ assert.equal(state.requirement_confirmed, true, 'a defect does not un-agree the requirement')
90
+ assert.equal(state.solution_confirmed, true, 'nor the approach')
91
+ assert.equal(state.verification.passed, false, 'the evidence about the old implementation falls')
92
+ assert.deepEqual(state.commits, [], 'the commit described a superseded tree')
93
+ })
94
+
95
+ test('rework: clearing evidence returns an item to unfinished, not to done-without-audit', () => {
96
+ // A cleared review means the item is no longer proven done. Leaving it `done`
97
+ // would let todos_done pass on work whose audit had just been discarded.
98
+ const state = loadedTask()
99
+ applyRevision(state, rework('defect', '开发'))
100
+ assert.equal(state.items[0].status, 'doing')
101
+ const config = adoptedFlow('standard')
102
+ assert.ok(completionBlockers({ ...state, stage: '完成' }, config).length > 0,
103
+ 'a task with an unproven item must not be completable')
104
+ })
105
+
106
+ test('rework: history records what was invalidated, for audit', () => {
107
+ const state = loadedTask()
108
+ applyRevision(state, rework('solution', '设计'))
109
+ assert.equal(state.revisions.length, 1)
110
+ assert.equal(state.revisions[0].kind, 'solution')
111
+ assert.equal(state.revisions[0].from, '代码审核')
112
+ assert.equal(state.revisions[0].to, '设计')
113
+ assert.deepEqual(state.revisions[0].invalidated, invalidatedBy('solution'))
114
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Config round-trip: what the panel shows is what the file holds.
3
+ *
4
+ * The write path and the panel both carried only `flow` + `stage_bindings`, so a
5
+ * commit rule, artifact declarations or a review depth were resolved for the preview
6
+ * and then dropped on save. A user could set them, see them applied, and lose them.
7
+ */
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { resolveFlow, adoptRecommendation } from './lib/workflows.js'
11
+
12
+ /** The fields a project config can carry, so a round-trip is checked on all of them. */
13
+ const FIELDS = ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth', 'commit_required']
14
+
15
+ test('round-trip: every configurable field survives being written and read back', () => {
16
+ // The save path serialises the payload and the read path parses it back through
17
+ // resolveFlow, so this asserts exactly those two steps.
18
+ const adopted = adoptRecommendation('standard')
19
+ const written = {
20
+ flow: 'standard',
21
+ stage_bindings: adopted.stage_bindings,
22
+ commit: adopted.commit,
23
+ artifacts: adopted.artifacts,
24
+ review_depth: 'single',
25
+ commit_required: false,
26
+ }
27
+ const serialised = JSON.stringify(written, null, 2)
28
+ const readBack = JSON.parse(serialised)
29
+ for (const field of FIELDS) {
30
+ assert.ok(field in readBack, `${field} must survive serialisation`)
31
+ }
32
+
33
+ const resolved = resolveFlow(readBack.flow, readBack)
34
+ assert.equal(resolved.ok, true)
35
+ assert.deepEqual(resolved.config.stage_bindings, written.stage_bindings, 'bindings round-trip')
36
+ assert.equal(resolved.config.commit.message_pattern, written.commit.message_pattern, 'the commit text convention round-trips')
37
+ assert.equal(resolved.config.commit.policy, 'task', 'the flow-control half of the commit rule is preserved')
38
+ assert.deepEqual(resolved.config.artifacts, written.artifacts, 'artifacts round-trip')
39
+ assert.equal(resolved.config.review_depth, 'single', 'review depth round-trips')
40
+ assert.equal(resolved.config.commit_required, false, 'commit_required round-trips')
41
+ })
42
+
43
+ test('round-trip: the payload the panel sends contains every edited field', () => {
44
+ // Mirrors the panel's save payload, so a field added to the config but not to the
45
+ // payload is caught here rather than by a user losing their settings.
46
+ const adopted = adoptRecommendation('standard')
47
+ const commitRule = adopted.commit
48
+ const artifacts = adopted.artifacts
49
+ const reviewDepth = 'single'
50
+ const payload = {
51
+ flow: 'standard',
52
+ stage_bindings: adopted.stage_bindings,
53
+ ...(commitRule !== undefined ? { commit: commitRule } : {}),
54
+ ...(artifacts !== undefined ? { artifacts } : {}),
55
+ ...(reviewDepth !== undefined ? { review_depth: reviewDepth } : {}),
56
+ }
57
+ for (const field of ['flow', 'stage_bindings', 'commit', 'artifacts', 'review_depth']) {
58
+ assert.ok(field in payload, `the panel must send ${field}`)
59
+ }
60
+ // And the whole payload must be accepted by the resolver the write path uses.
61
+ const resolved = resolveFlow(payload.flow, payload)
62
+ assert.equal(resolved.ok, true, 'the payload the panel sends must resolve without problems')
63
+ })