@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/.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,34 +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 skill bindings cannot be cancelled by an override ──────────────
112
- // Rules moved onto the skill, so the invariant is about the SKILL surviving an
113
- // override and that skill's own rule list staying intact. A stage has no rule
114
- // list left to empty.
115
- const merged = resolveFlow('standard', { stage_bindings: { '开发': {} } })
116
- assert(merged.ok
117
- && merged.config.stage_bindings['开发'].skills.some(entry => entry.skill.name === 'code-implement'),
118
- 'an empty stage override keeps the core skill binding')
119
- const implementBinding = merged.config.stage_bindings['开发'].skills.find(entry => entry.skill.name === 'code-implement')
120
- assert(implementBinding.rules.map(rule => rule.name).includes('coding-conventions'),
121
- 'a skill keeps its own rules; there is no stage-level rule list for an override to empty')
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')
122
169
 
123
170
  // ── 4. newTask freezes the snapshot ────────────────────────────────────────
124
- const snapshot = { flow: 'standard', version: 1, config: FLOW_PRESETS.standard.config }
171
+ const snapshot = { flow: 'standard', version: 1, config: adoptedFlow('standard') }
125
172
  const state = newTask({ id: 'T1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
126
173
  assert(state.flow?.flow === 'standard' && state.flow.version === 1, 'newTask stores the frozen snapshot')
127
174
  assert(state.stage === '需求评审', 'newTask starts at the frozen start stage')
128
175
 
129
176
  // ── 5. high_risk create on minimal is rejected ─────────────────────────────
130
177
  await assertThrows(
131
- () => registered(makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) }))
178
+ () => registered(makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('minimal')) }))
132
179
  .then(exe => exe({ operation: 'create', task_id: 'T2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)),
133
180
  'high_risk',
134
181
  'high_risk on minimal is rejected',
@@ -136,13 +183,13 @@ await assertThrows(
136
183
 
137
184
  // ── 6. a task keeps its frozen snapshot after eng.json changes ─────────────
138
185
  {
139
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
186
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
140
187
  const exe = await registered(fs)
141
188
  const created = await exe({ operation: 'create', task_id: 'T3', title: 'x', branch: 'main' }, EXEC)
142
189
  assert(created.includes('需求评审'), 'standard create starts at 需求评审')
143
- fs._files.set('.dsh/eng.json', JSON.stringify({ flow: 'minimal' }))
190
+ fs._files.set('.dsh/eng.json', JSON.stringify(adoptRecommendation('minimal')))
144
191
  const status = JSON.parse(await exe({ operation: 'status', task_id: 'T3' }, EXEC))
145
- 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')
146
193
  assert(Array.isArray(status.legal_next) && status.legal_next.includes('设计'),
147
194
  'gates come from the frozen standard flow, not the live minimal flow')
148
195
  }
@@ -213,22 +260,59 @@ await assertThrows(
213
260
  )
214
261
  }
215
262
 
216
- // ── 11. a project cannot shadow a bundled core rule ────────────────────────
217
- {
218
- const bundled = readFileSync('./rules/security-redlines.md', 'utf8')
219
- assert(bundled.includes('服务端'), 'bundled security-redlines exists as expected')
220
- const fs = makeFs({
221
- '.dsh/eng.json': JSON.stringify({ flow: 'standard' }),
222
- '.dsh/rules/security-redlines.md': '# 弱化的安全红线,前端校验即可',
223
- })
224
- const exe = await registered(fs)
225
- await exe({ operation: 'create', task_id: 'T9', title: 'x', branch: 'main' }, EXEC)
226
- const status = JSON.parse(await exe({ operation: 'status', task_id: 'T9' }, EXEC))
227
- const sec = status.bindings.rules.find(r => r.name === 'security-redlines')
228
- assert(sec !== undefined, 'security-redlines is disclosed at the start stage')
229
- assert(!sec.content.includes('前端校验即可'), 'a project shadow of a core rule is ignored')
230
- assert(sec.content.includes('服务端'), 'the bundled core rule body wins over a shadow')
231
- }
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
+ }
232
316
 
233
317
  // ── 12. session cwd drives every relative path (init regression) ──────────
234
318
  {
@@ -246,7 +330,7 @@ await assertThrows(
246
330
  // ── 13. create writes the task record into the session workspace ──────────
247
331
  {
248
332
  const cwd = 'C:/users/ggbond/.dsh-verify/e2e-project'
249
- const fs = makeFs({ [`${cwd}/.dsh/eng.json`]: JSON.stringify({ flow: 'standard' }) })
333
+ const fs = makeFs({ [`${cwd}/.dsh/eng.json`]: JSON.stringify(adoptRecommendation('standard')) })
250
334
  const exe = await registered(fs)
251
335
  await exe({ operation: 'create', task_id: 'T12', title: 'x', branch: 'main' }, sessionExec(cwd))
252
336
  assert(fs._files.get(`${cwd}/.dsh/task-T12.json`) !== undefined, 'task record lands in the session workspace')
@@ -255,7 +339,7 @@ await assertThrows(
255
339
 
256
340
  // ── 14. K: artifact ids must be unique across ALL stages ────────────────────
257
341
  {
258
- const withDup = { ...FLOW_PRESETS.standard.config, artifacts: [
342
+ const withDup = { ...adoptedFlow('standard'), artifacts: [
259
343
  { stage: '需求评审', id: 'doc', name: '需求', fields: ['scope'] },
260
344
  { stage: '设计', id: 'doc', name: '设计', fields: ['approach'] },
261
345
  ] }
@@ -266,7 +350,7 @@ await assertThrows(
266
350
 
267
351
  // ── 15. K: record rejects an artifact owned by another stage ─────────────────
268
352
  {
269
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
353
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
270
354
  const exe = await registered(fs)
271
355
  await exe({ operation: 'create', task_id: 'K1', title: 'x', branch: 'main' }, EXEC)
272
356
  await assertThrows(
@@ -278,7 +362,7 @@ await assertThrows(
278
362
 
279
363
  // ── 16. L/4.2: high-risk verification requires a REAL command receipt ────────
280
364
  {
281
- const cfg = FLOW_PRESETS.standard.config
365
+ const cfg = adoptedFlow('standard')
282
366
  const receipt = overrides => ({
283
367
  command: 'npm test', exit_code: 0, timed_out: false, aborted: false,
284
368
  started_at: '2026-01-01T00:00:00Z', finished_at: '2026-01-01T00:00:01Z',
@@ -303,20 +387,20 @@ await assertThrows(
303
387
 
304
388
  // ── 17. J: task id extracted from the summary (the hook uses it, not a guess) ─
305
389
  {
306
- const cfg = FLOW_PRESETS.standard.config
390
+ const cfg = adoptedFlow('standard')
307
391
  assert(taskIdFromMessage('【GREET-001】【TASK】实现登录', cfg) === 'GREET-001', 'standard summary yields its task id')
308
392
  assert(taskIdFromMessage('【GREET-001】【T1】实现登录', cfg) === 'GREET-001', 'item-label summary yields its task id')
309
393
  assert(taskIdFromMessage('实现登录', cfg) === undefined, 'non-matching summary yields no task id')
310
- 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')
311
395
  }
312
396
 
313
397
  // ── 18. N: engine bookkeeping files are exempt from file scope ───────────────
314
398
  {
315
399
  const s = newTask({ id: 'N1', title: 'x', branch: 'main', work_size: 'standard', risk_level: 'standard', flow: snapshot })
316
400
  s.files = ['src/a.ts']
317
- 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,
318
402
  'task record and eng.json do not violate file scope')
319
- assert(!checkFileScope(s, ['src/b.ts'], FLOW_PRESETS.standard.config).ok,
403
+ assert(!checkFileScope(s, ['src/b.ts'], adoptedFlow('standard')).ok,
320
404
  'an out-of-scope code file is still flagged')
321
405
  }
322
406
 
@@ -447,7 +531,7 @@ function memProbe(files) {
447
531
  // ── 23. 0.22-A: snapshot hash integrity ────────────────────────────────────
448
532
  {
449
533
  const { hashConfig } = await import('./lib/snapshot.js')
450
- const config = FLOW_PRESETS.standard.config
534
+ const config = adoptedFlow('standard')
451
535
  const h1 = hashConfig(config)
452
536
  const h2 = hashConfig(JSON.parse(JSON.stringify(config)))
453
537
  assert(h1 === h2 && /^[0-9a-f]{64}$/.test(h1), 'hashConfig is a deterministic sha256')
@@ -456,7 +540,7 @@ function memProbe(files) {
456
540
  assert(hashConfig(tampered) !== h1, 'editing the frozen config changes the hash')
457
541
  }
458
542
  {
459
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
543
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
460
544
  const exe = await registered(fs)
461
545
  await exe({ operation: 'create', task_id: 'HASH-1', title: 'x', branch: 'main' }, EXEC)
462
546
  const record = JSON.parse(fs._files.get('.dsh/task-HASH-1.json'))
@@ -491,7 +575,7 @@ function memProbe(files) {
491
575
 
492
576
  // ── 25. 0.22-B: risk downgrade approval + bindings fingerprint ─────────────
493
577
  {
494
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
578
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
495
579
  const exe = await registered(fs)
496
580
  await exe({ operation: 'create', task_id: 'RISK-1', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
497
581
  await assertThrows(
@@ -504,7 +588,7 @@ function memProbe(files) {
504
588
  }
505
589
  {
506
590
  const approval = { async request() { return 'allowed-once' } }
507
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
591
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
508
592
  const exe = await registered(fs, approval)
509
593
  await exe({ operation: 'create', task_id: 'RISK-2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
510
594
  assert((await exe({ operation: 'set_risk', task_id: 'RISK-2', risk_level: 'standard' }, EXEC)).includes('risk_level set to standard'),
@@ -514,7 +598,7 @@ function memProbe(files) {
514
598
  'downgrade is recorded in the audit trail')
515
599
  }
516
600
  {
517
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
601
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
518
602
  const exe = await registered(fs)
519
603
  await exe({ operation: 'create', task_id: 'FP-1', title: 'x', branch: 'main' }, EXEC)
520
604
  const status = JSON.parse(await exe({ operation: 'status', task_id: 'FP-1' }, EXEC))
@@ -536,7 +620,7 @@ function memProbe(files) {
536
620
  )
537
621
  }
538
622
  {
539
- 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': '{}' })
540
624
  const exe = await registered(fs)
541
625
  await exe({ operation: 'create', task_id: 'V-2', title: 'x', branch: 'main' }, EXEC)
542
626
  await assertThrows(
@@ -546,7 +630,7 @@ function memProbe(files) {
546
630
  )
547
631
  }
548
632
  {
549
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
633
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
550
634
  const exe = await registered(fs)
551
635
  await exe({ operation: 'create', task_id: 'V-3', title: 'x', branch: 'main' }, EXEC)
552
636
  await assertThrows(() => exe({ operation: 'verify', task_id: 'V-3', passed: true }, EXEC),
@@ -558,7 +642,7 @@ function memProbe(files) {
558
642
  'legacy tasks retain their verification contract')
559
643
  }
560
644
  {
561
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
645
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
562
646
  const exe = await registered(fs)
563
647
  await exe({ operation: 'create', task_id: 'A-1', title: 'x', branch: 'main' }, EXEC)
564
648
  await exe({ operation: 'create', task_id: 'B-2', title: 'y', branch: 'main' }, EXEC)
@@ -594,7 +678,7 @@ function memProbe(files) {
594
678
 
595
679
  // ── 29. dev_task writes carry the per-call sandbox policy ───────────────────
596
680
  {
597
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
681
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
598
682
  let seenPolicy
599
683
  const origWrite = fs.writeText
600
684
  fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
@@ -616,7 +700,7 @@ function memProbe(files) {
616
700
 
617
701
  // ── 30. 0.22.x: sandbox escalation requires a human one-shot approval ───────
618
702
  {
619
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
703
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
620
704
  let seenPolicy
621
705
  const origWrite = fs.writeText
622
706
  fs.writeText = async (target, content, expected, signal, sandboxPolicy) => {
@@ -634,7 +718,7 @@ function memProbe(files) {
634
718
  }
635
719
 
636
720
  {
637
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
721
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
638
722
  const exe = await registered(fs) // no approval service
639
723
  await assertThrows(
640
724
  () => exe({
@@ -648,7 +732,7 @@ function memProbe(files) {
648
732
 
649
733
  // ── 31. 0.22.x: set_risk cannot raise risk past the flow's capabilities ─────
650
734
  {
651
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'minimal' }) })
735
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('minimal')) })
652
736
  const exe = await registered(fs)
653
737
  await exe({ operation: 'create', task_id: 'SR-1', title: 'x', branch: 'main' }, EXEC)
654
738
  await assertThrows(
@@ -658,7 +742,7 @@ function memProbe(files) {
658
742
  )
659
743
  }
660
744
  {
661
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
745
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
662
746
  const exe = await registered(fs)
663
747
  await exe({ operation: 'create', task_id: 'SR-2', title: 'x', branch: 'main' }, EXEC)
664
748
  assert((await exe({ operation: 'set_risk', task_id: 'SR-2', risk_level: 'high_risk' }, EXEC)).includes('high_risk'),
@@ -674,7 +758,7 @@ function memProbe(files) {
674
758
 
675
759
  // ── 33. 0.23: verify runs in the task's recorded project root ──────────────
676
760
  {
677
- 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': '{}' })
678
762
  const seenWorkdirs = []
679
763
  const shell = {
680
764
  resolve(request) { return { command: request.command, workdir: request.workdir ?? '', timeoutMs: request.timeoutMs } },
@@ -694,7 +778,7 @@ function memProbe(files) {
694
778
 
695
779
  // ── 34. 0.23: revision increments on every write (CAS foundation) ───────────
696
780
  {
697
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
781
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
698
782
  const exe = await registered(fs)
699
783
  await exe({ operation: 'create', task_id: 'CAS-1', title: 'x', branch: 'main' }, EXEC)
700
784
  const r1 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
@@ -724,7 +808,7 @@ function memProbe(files) {
724
808
 
725
809
  // A second writer between content read and write must not be silently overwritten.
726
810
  {
727
- const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
811
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify(adoptRecommendation('standard')) })
728
812
  const exe = await registered(fs)
729
813
  await exe({ operation: 'create', task_id: 'CAS-RACE', title: 'race', branch: 'main' }, EXEC)
730
814
  const originalRead = fs.readText.bind(fs)
@@ -11,7 +11,22 @@
11
11
  import test from 'node:test'
12
12
  import assert from 'node:assert/strict'
13
13
  import { applyRevision, invalidatedBy, completionBlockers } from './lib/engine.js'
14
- import { resolveFlow } from './lib/workflows.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
+
15
30
 
16
31
  /** A task carrying every conclusion, so a rework has something to invalidate. */
17
32
  function loadedTask() {
@@ -83,7 +98,7 @@ test('rework: clearing evidence returns an item to unfinished, not to done-witho
83
98
  const state = loadedTask()
84
99
  applyRevision(state, rework('defect', '开发'))
85
100
  assert.equal(state.items[0].status, 'doing')
86
- const config = resolveFlow('standard', {}).config
101
+ const config = adoptedFlow('standard')
87
102
  assert.ok(completionBlockers({ ...state, stage: '完成' }, config).length > 0,
88
103
  'a task with an unproven item must not be completable')
89
104
  })
@@ -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
+ })
@@ -5,14 +5,41 @@ import { tmpdir } from 'node:os'
5
5
  import { join, resolve, sep } from 'node:path'
6
6
  import { registerDevTask } from './lib/dev-task.js'
7
7
  import { newTask } from './lib/engine.js'
8
- import { resolveFlow } from './lib/workflows.js'
8
+ import { adoptRecommendation, resolveFlow } from './lib/workflows.js'
9
9
  import Controller from './lib/controller.js'
10
10
  import { registerShippedSkills } from './lib/shipped-skills.js'
11
11
 
12
+ /**
13
+ * The preset as a project would actually run it: the skeleton plus the shipped
14
+ * recommendation, adopted exactly the way a user adopts it. A test that asserts
15
+ * on bindings, commit rules or artifacts wants this, because those no longer come
16
+ * from the preset itself.
17
+ * @param {string} id - preset id.
18
+ * @returns {import('./lib/engine.js').WorkflowConfig} the adopted config.
19
+ */
20
+ function adoptedFlow(id, extra) {
21
+ const base = adoptRecommendation(id)
22
+ if (base === undefined) throw new Error('unknown preset: ' + id)
23
+ return resolveFlow(id, { ...base, ...extra }).config
24
+ }
25
+
26
+
12
27
  function fixture(stage = '开发', extra = {}, services = {}) {
13
28
  const cwd = resolve('test-project')
14
- const config = resolveFlow('standard', { stage_bindings: { 完成: { skills: [{ skill: { source: 'bundled', name: 'software-testing' }, rules: [] }] } } }).config
15
- const state = newTask({ id: 'LIVE-1', title: 'EAM regression', branch: 'test', work_size: 'standard', risk_level: 'standard', flow: { flow: 'standard', version: 2, config }, root: cwd })
29
+ // A project running the standard flow: the skeleton plus the adopted
30
+ // recommendation, with one extra skill mounted on 完成 for the tests that need
31
+ // a binding beyond the shipped set. The bindings are MERGED, not replaced —
32
+ // replacing them would drop the recommended skills and artifacts the tests
33
+ // assert on, since the preset skeleton itself carries none.
34
+ const adopted = adoptRecommendation('standard')
35
+ const config = resolveFlow('standard', {
36
+ ...adopted,
37
+ stage_bindings: {
38
+ ...adopted.stage_bindings,
39
+ 完成: { skills: [{ skill: { source: 'bundled', name: 'software-testing' }, rules: [] }] },
40
+ },
41
+ }).config
42
+ const state = newTask({ id: 'LIVE-1', title: 'EAM regression', branch: 'test', work_size: 'standard', risk_level: 'standard', flow: { flow: 'standard', version: 3, config }, root: cwd })
16
43
  Object.assign(state, { stage, execution_version: 1, files: ['app.js'], requirement_confirmed: true, solution_confirmed: true, ...extra })
17
44
  const records = new Map([[join(cwd, '.dsh/task-LIVE-1.json'), JSON.stringify(state)], [join(cwd, 'app.js'), 'source']])
18
45
  const events = [], runs = []
@@ -168,7 +195,7 @@ test('status 披露当前阶段记录字段和缺项,字段误用不写入且
168
195
  })
169
196
 
170
197
  test('记录字段来自冻结流程,精简流程不硬编码标准字段,无记录阶段返回空列表', async () => {
171
- const config = resolveFlow('agile').config
198
+ const config = adoptedFlow('agile')
172
199
  const f = fixture('需求', { flow: { flow: 'agile', version: 1, config } })
173
200
  assert.deepEqual(JSON.parse(await f.call({ operation: 'status' })).artifact_requirements, [{ id: 'requirement', name: '需求说明', fields: ['scope'], missing_fields: ['scope'] }])
174
201
  await f.call({ operation: 'record', artifact: 'requirement', fields: { scope: '目标、验收与疑问' } })
package/README.md CHANGED
@@ -27,7 +27,7 @@
27
27
  让 AI 修改代码时,你可以先明确需求,再检查方案、推进实施、验证结果,最后审核提交。DSH Task Engine 把这些步骤组织成可检查、可追踪的任务流程,适合在本机使用 DeepSeek Harness 开发个人项目。
28
28
 
29
29
  - **知道下一步做什么**:按所选流程推进,当前阶段需要的条件和产物清楚可查。
30
- - **复用自己的工作方法**:把技能和规则安装到项目或个人目录,再挂到对应阶段。
30
+ - **复用自己的工作方法**:把技能和规则安装到项目或个人目录,再挂到节点上;规则跟着技能走,所以同一技能在任何节点都是同一套约束。
31
31
  - **找得到过程记录**:任务台账集中查看阶段、实施项、验证与审核状态。
32
32
 
33
33
  ## 快速开始
@@ -51,13 +51,15 @@ dsh plugin --profile web add @godv61/dsh-task-engine
51
51
  | 页面 | 你可以做什么 |
52
52
  | :--- | :--- |
53
53
  | **项目初始化** | 查看或编辑 `AGENTS.md`,让 AI 生成项目说明,预览后保存。 |
54
- | **流程配置** | 选择内置流程,为各阶段追加技能和规则。 |
54
+ | **流程配置** | 选择流程骨架,给节点挂技能,并在技能上配置它遵循的规则。需要现成起点时可「采用推荐配置」。 |
55
55
  | **任务台账** | 查看实施、验证和审核记录,按关键词、阶段或风险筛选。 |
56
56
  | **技能** | 选择技能文件夹,预览内容与目标目录后安装;支持查看、编辑和删除。 |
57
57
  | **规则** | 选择 Markdown 文件安装规则,维护项目或个人开发约定。 |
58
58
 
59
59
  内置技能和规则只读;项目与个人资源可编辑。安装、删除都会明确展示目标位置。
60
60
 
61
+ **流程与工作方法是两回事。** 流程只决定工作怎么流转(阶段顺序与门禁),预设不自带任何技能或提交格式。要用什么技能、遵守哪些规则、提交信息写什么,都由你配置——「采用推荐配置」只是把一份现成的起点**写入**你的配置,之后完全归你:删掉的不再补回,升级也不覆盖。规则挂在技能下,所以打开一个技能看到的就是它完整的规则列表,挂到任何节点都一样。
62
+
61
63
  ## 选择适合这次任务的流程
62
64
 
63
65
  | 预设 | 阶段顺序 | 适用场景 |
package/docs/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@
4
4
 
5
5
  按版本查阅功能变化。当前使用方式以[项目首页](../README.md)和使用指南为准;历史条目中的实现方式、限制与测试数量可能已被后续版本替代。
6
6
 
7
+ ## 0.25.0
8
+
9
+ 针对 2026-09-24 复评的改造。**这是一个破坏性版本**:预设不再自带任何技能、产物或提交格式,`SkillBinding` 新增 `evidence` 字段,敏捷流程的门禁语义与版本号都变了。核心是落实产品原则:**流程只控制状态,业务方法由用户配置,Rule 真正归属 Skill。**
10
+
11
+ 已有项目不会因此失效,但**行为会变**:预设不再提供内置技能,所以一个从未采用推荐配置的项目,其节点将没有绑定。升级后打开工作台点一次「采用推荐配置」即可获得与升级前等价的起点,此后完全由你控制——删除的不再补回,升级也不覆盖。已有任务按各自的冻结快照执行,不受影响。
12
+
13
+ ### 流程骨架与可选推荐配置(破坏性)
14
+
15
+ 预设此前把技能、提交规则和产物字段烘焙进解析结果,`mergeBindings` 又只做**追加**——于是内置技能无法移除,而用户给内置技能配自己的规则时,条目会因引用已存在被整个跳过,静默保留预设规则。工作台里选择流程也会一并写入这些内容,等于选择流程即接受内置工作方法。
16
+
17
+ 现在分成两层:
18
+
19
+ - **骨架**只含阶段图、每条边的守卫,以及提交规则中属于**流程控制**的那一半:何时需要提交、哪个节点是检查点、是否强制文件范围。最后一项留在骨架上是有意的——高风险检查读它作为安全底线,不能因为用户没采用推荐配置就消失。
20
+ - **推荐配置**(`FlowRecommendation`)单独存放技能绑定、提交**文本**约定、产物字段与审查深度。
21
+ - `adoptRecommendation()` 只在用户显式请求时写入。这是一次性动作:此后这些值就是用户的普通配置,用户删除的值不会回来,升级也不覆盖。
22
+ - `resolveFlow` 逐字采用项目配置。用户没配的节点就是没有绑定,这是合法状态,不是待补齐的空缺。
23
+ - 工作台新增「采用推荐配置」按钮;**选择流程不再写入任何绑定**。
24
+
25
+ ### 冻结真正接入执行链
26
+
27
+ 此前 `flow.resources` 只**存档**正文,`status` 与阶段披露仍走 `readRuleAt` 读实时文件——改一条规则会无声改变进行中任务所遵循的内容,正是快照本该阻止的不稳定。现在解析优先使用冻结正文,并把漂移**报告**出来:源被改动记为 `stale_source_rules`(source edited),源被删除也记为漂移而非缺失——任务手里已有内容,说它缺失是错的。没有快照的旧任务继续读实时文件并把删除报为缺失,两条路径可区分,不会互相悄悄退化。
28
+
29
+ ### 缺失资源、节点结果与完成条件改为真正阻塞
30
+
31
+ - **规则无法解析则不可推进**。`missing_rules` 以前只披露不阻塞,删掉绑定规则仍能推进——阶段在缺少既定约束的情况下走完,而记录看起来正常。判定实现为引擎里的纯函数,工具与 hook 从同样输入得到同样结论。
32
+ - **完成检查流程声明的节点结果**。此前只查「是否站在终态、实施项是否完成、有无提交」,于是一个在进入终态的路上声明了 `review_passed` 的流程,只要**到达**就满足——审核被 blocked 仍能完成。要求由 `terminalRequirements` 从流程推导,没声明验证门禁的流程不会被强加。
33
+ - 敏捷流程的审查要求需要另一种机制:`审查` 是终态且**就是**审查本身,没有出边可承载守卫。把 `review_passed` 挂到 `交付 → 审查` 会要求「在产生结论的阶段之前就有结论」,还会挡住 `交付` 的提交(提交要求其出边守卫全部满足)。改用 `completion_guards` 显式声明,这正是 `complete` 作为独立操作存在的原因。敏捷版本号升至 4。
34
+
35
+ ### 技能声明自己需要哪类证据
36
+
37
+ 每种非内置技能都必须记录「真实验收命令」,于是需求或设计类技能——产出是一份文档——被迫运行一条无关命令来满足门禁。证据本来就有,只是形态不同。技能绑定现在可声明 `evidence`:`command` / `artifact` / `review` / `manual` / `none`,引擎按各自形态检查对应记录。未声明时保持历史行为(命令回执),因此既有配置与冻结快照不受影响。
38
+
39
+ ### 保存完整配置 + 列表可用性
40
+
41
+ - **保存不再只写 `flow` 与 `stage_bindings`**。提交规则、产物声明、审查深度与 `commit_required` 以前只用于预览,保存时被静默丢弃——面板显示一套配置、文件里是另一套。写入载荷与面板状态现在都覆盖全部字段。
42
+ - **技能与规则列表在规模增长后可用**:加搜索(匹配名称、说明与来源层)、「仅看已选」筛选,展开的规则列表改为**有界滚动区**。一条规则贯穿全部筛选:**已绑定或预设自带的条目任何查询下都保持可见**——面板展示的是配置本身,筛选藏起正在生效的资源会让面板与它显示的配置不一致,那比列表长更糟。
43
+
7
44
  ## 0.24.0
8
45
 
9
46
  针对 2026-09-23 外部深度评测的改造。**这是一个破坏性版本**:配置里的 `stage_bindings[*].rules` 不再作为节点级规则读取,`skills` 的格式也从字符串数组改为带来源的对象。已有配置会继续工作——旧的节点级规则以 `legacy_rules` 保留并**仍然生效**——但它们的归属需要你确认一次,见下面「规则改为只挂在技能下」。