@godv61/dsh-task-engine 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.acceptance.mjs +150 -88
- package/.assessment-batch1.mjs +32 -12
- package/.e2e-presets.mjs +40 -5
- package/.enforce-test.mjs +139 -0
- package/.evidence-test.mjs +119 -0
- package/.filter-test.mjs +93 -0
- package/.freeze-test.mjs +110 -1
- package/.hook-test.mjs +8 -3
- package/.p0-test.mjs +141 -57
- package/.revision-test.mjs +17 -2
- package/.roundtrip-test.mjs +63 -0
- package/.workflow-test.mjs +31 -4
- package/README.md +4 -2
- package/docs/CHANGELOG.md +37 -0
- package/docs/configuration.md +3 -3
- package/hooks/commit-msg +93 -75
- package/lib/client.js +218 -81
- package/lib/client.js.map +1 -1
- package/lib/controller.d.ts +10 -2
- package/lib/controller.js +27 -2
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +94 -20
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +90 -0
- package/lib/engine.js +64 -0
- package/lib/engine.js.map +1 -1
- package/lib/hook.js +14 -4
- package/lib/hook.js.map +1 -1
- package/lib/skill-audit.d.ts +14 -3
- package/lib/skill-audit.js +66 -3
- package/lib/skill-audit.js.map +1 -1
- package/lib/workflows.d.ts +75 -12
- package/lib/workflows.js +172 -96
- package/lib/workflows.js.map +1 -1
- package/package.json +9 -5
- package/scripts/verify-package.mjs +1 -1
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
|
-
|
|
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.
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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(
|
|
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 = { ...
|
|
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(
|
|
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 =
|
|
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 =
|
|
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('随意',
|
|
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'],
|
|
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'],
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
package/.revision-test.mjs
CHANGED
|
@@ -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 =
|
|
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
|
+
})
|
package/.workflow-test.mjs
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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 =
|
|
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` 保留并**仍然生效**——但它们的归属需要你确认一次,见下面「规则改为只挂在技能下」。
|