@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/.acceptance.mjs +151 -0
- package/.assessment-batch1.mjs +423 -0
- package/.e2e-presets.mjs +230 -0
- package/.enforce-test.mjs +139 -0
- package/.evidence-test.mjs +119 -0
- package/.filter-test.mjs +93 -0
- package/.freeze-test.mjs +199 -0
- package/.hook-consistency.mjs +60 -0
- package/.hook-test.mjs +8 -3
- package/.p0-test.mjs +141 -50
- package/.revision-test.mjs +114 -0
- package/.roundtrip-test.mjs +63 -0
- package/.workflow-test.mjs +94 -65
- package/README.md +4 -2
- package/defaults/eng.json +45 -6
- package/docs/BRIEF-FOR-REVIEW.md +163 -0
- package/docs/CHANGELOG.md +96 -0
- package/docs/configuration.md +31 -6
- package/hooks/commit-msg +162 -65
- package/lib/client.js +638 -431
- package/lib/client.js.map +3 -3
- package/lib/controller.d.ts +18 -1
- package/lib/controller.js +64 -5
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +363 -47
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +368 -6
- package/lib/engine.js +328 -13
- 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 +71 -4
- package/lib/skill-audit.js.map +1 -1
- package/lib/workflows.d.ts +75 -12
- package/lib/workflows.js +213 -77
- package/lib/workflows.js.map +1 -1
- package/package.json +14 -4
- 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,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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
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 = { ...
|
|
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(
|
|
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 =
|
|
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 =
|
|
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('随意',
|
|
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'],
|
|
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'],
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
})
|