@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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Resource freezing: an in-flight task must not change because the library did.
3
+ *
4
+ * Names alone cannot keep a task stable. Before this, editing a rule that a
5
+ * running task used silently changed what that task was doing, and deleting one
6
+ * made a configured constraint disappear without the task recording anything.
7
+ */
8
+ import { adoptRecommendation } from './lib/workflows.js'
9
+ import test from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import { join, resolve } from 'node:path'
12
+ import { registerDevTask } from './lib/dev-task.js'
13
+ import { hashText } from './lib/snapshot.js'
14
+
15
+ /** Create one task through the real tool and return its frozen snapshot. */
16
+ async function created(flow) {
17
+ const cwd = resolve('freeze-project')
18
+ const records = new Map([
19
+ // A project that adopted the shipped recommendation, which is what gives it
20
+ // skills and rules to freeze. `{flow}` alone adopts nothing and therefore has
21
+ // nothing to freeze — a legitimate state, but not the one under test.
22
+ [join(cwd, '.dsh/eng.json'), JSON.stringify(adoptRecommendation(flow))],
23
+ [join(cwd, 'a.js'), 'source'],
24
+ ])
25
+ const events = []
26
+ const session = { id: 'f', header: { cwd }, snapshotEvents: () => events }
27
+ let execute
28
+ const key = (p, options) => join(options?.cwd ?? cwd, p)
29
+ const fs = {
30
+ resolve: async (p, options) => ({ targetKey: key(p, options) }),
31
+ readText: async target => records.get(target.targetKey),
32
+ listDir: async () => [],
33
+ lstat: async (p, options) => records.has(key(p, options)) ? { version: 'v' } : undefined,
34
+ writeText: async (target, content) => { records.set(target.targetKey, content) },
35
+ }
36
+ const ctx = {
37
+ fs,
38
+ tools: { register(tool) { execute = tool.execute; return () => {} } },
39
+ get: name => name === 'sandboxPolicy'
40
+ ? { resolve: () => ({ mode: 'workspace-write', workspaceRoot: cwd, sessionId: 'f' }) }
41
+ : undefined,
42
+ }
43
+ registerDevTask(ctx)
44
+ const exec = { agent: { session }, signal: new AbortController().signal }
45
+ await execute({ task_id: 'FZ-1', operation: 'create', title: 'freeze', branch: 'main', files: ['a.js'] }, exec)
46
+ return JSON.parse(records.get(join(cwd, '.dsh/task-FZ-1.json')))
47
+ }
48
+
49
+ test('freeze: task creation captures every skill and rule body it will use', async () => {
50
+ const state = await created('standard')
51
+ const resources = state.flow.resources ?? []
52
+ assert.ok(resources.length > 0, 'creation must freeze the resolved resources')
53
+
54
+ // Every binding in the frozen config must be covered by a frozen body.
55
+ const frozen = new Set(resources.map(r => `${r.ref.source}:${r.ref.name}`))
56
+ for (const [stage, binding] of Object.entries(state.flow.config.stage_bindings ?? {})) {
57
+ for (const entry of binding.skills ?? []) {
58
+ assert.ok(frozen.has(`${entry.skill.source}:${entry.skill.name}`),
59
+ `${entry.skill.name} on ${stage} must be frozen`)
60
+ for (const rule of entry.rules) {
61
+ assert.ok(frozen.has(`${rule.source}:${rule.name}`),
62
+ `rule ${rule.name} for ${entry.skill.name} must be frozen`)
63
+ }
64
+ }
65
+ }
66
+ })
67
+
68
+ test('freeze: a frozen body carries its content and a hash of that content', async () => {
69
+ const state = await created('standard')
70
+ for (const resource of state.flow.resources ?? []) {
71
+ assert.ok(resource.content.length > 0, `${resource.ref.name} must carry its body`)
72
+ assert.equal(resource.hash, hashText(resource.content),
73
+ `${resource.ref.name} hash must describe the frozen body, so a later edit is detectable`)
74
+ }
75
+ })
76
+
77
+ test('freeze: shared resources are stored once, not duplicated per reference', async () => {
78
+ // security-redlines is referenced by two skills; the body must not be copied twice.
79
+ const state = await created('standard')
80
+ const resources = state.flow.resources ?? []
81
+ const hashes = resources.map(r => r.hash)
82
+ assert.equal(new Set(hashes).size, hashes.length,
83
+ 'identical bodies must not be stored more than once')
84
+ assert.ok(resources.some(r => r.ref.name === 'security-redlines'),
85
+ 'the shared rule must be present')
86
+ })
87
+
88
+ test('freeze: every preset freezes a complete, non-empty set', async () => {
89
+ for (const flow of ['standard', 'agile', 'minimal']) {
90
+ const state = await created(flow)
91
+ assert.ok((state.flow.resources ?? []).length >= 3,
92
+ `${flow} must freeze its resources; saw ${(state.flow.resources ?? []).length}`)
93
+ }
94
+ })
95
+
96
+ /**
97
+ * A task carrying one project rule on a skill, parked at the stage that uses it.
98
+ *
99
+ * Built from the adopted recommendation so the binding is one the resolver really
100
+ * accepts, then given a project rule of its own — exactly what the workbench
101
+ * writes when a user edits a skill's rule list.
102
+ */
103
+ async function atDevelopmentWithProjectRule() {
104
+ const cwd = resolve('freeze-live')
105
+ const adopted = adoptRecommendation('standard')
106
+ const skills = adopted.stage_bindings['开发'].skills.map(entry => ({
107
+ ...entry,
108
+ rules: [...entry.rules, { source: 'project', name: 'mine' }],
109
+ }))
110
+ const project = { ...adopted, stage_bindings: { ...adopted.stage_bindings, '开发': { skills } } }
111
+ const records = new Map([
112
+ [join(cwd, '.dsh/eng.json'), JSON.stringify(project)],
113
+ [join(cwd, '.dsh/rules/mine.md'), 'ORIGINAL RULE'],
114
+ [join(cwd, 'a.js'), 'source'],
115
+ ])
116
+ let execute
117
+ const key = (p, options) => join(options?.cwd ?? cwd, p)
118
+ const fs = {
119
+ resolve: async (p, options) => ({ targetKey: key(p, options) }),
120
+ readText: async target => records.get(target.targetKey),
121
+ listDir: async () => [],
122
+ lstat: async (p, options) => records.has(key(p, options)) ? { version: 'v' } : undefined,
123
+ writeText: async (target, content) => { records.set(target.targetKey, content) },
124
+ }
125
+ const ctx = {
126
+ fs,
127
+ tools: { register(tool) { execute = tool.execute; return () => {} } },
128
+ get: name => name === 'sandboxPolicy'
129
+ ? { resolve: () => ({ mode: 'workspace-write', workspaceRoot: cwd, sessionId: 'f' }) }
130
+ : undefined,
131
+ }
132
+ registerDevTask(ctx)
133
+ const exec = { agent: { session: { id: 'f', header: { cwd }, snapshotEvents: () => [] } }, signal: new AbortController().signal }
134
+ const call = async args => execute(JSON.parse(JSON.stringify({ task_id: 'FZ-2', ...args })), exec)
135
+ await call({ operation: 'create', title: 'freeze', branch: 'main', files: ['a.js'] })
136
+ const state = JSON.parse(records.get(join(cwd, '.dsh/task-FZ-2.json')))
137
+ state.stage = '开发'
138
+ state.requirement_confirmed = true
139
+ state.solution_confirmed = true
140
+ records.set(join(cwd, '.dsh/task-FZ-2.json'), JSON.stringify(state))
141
+ return {
142
+ call,
143
+ records,
144
+ cwd,
145
+ rule: () => join(cwd, '.dsh/rules/mine.md'),
146
+ disclosed: async () => {
147
+ const status = JSON.parse(await call({ operation: 'status' }))
148
+ return { status, mine: status.bindings.rules.find(r => r.name === 'mine') }
149
+ },
150
+ }
151
+ }
152
+
153
+ test('freeze: a running task follows the FROZEN body after its source is edited', async () => {
154
+ // Freezing that only archives the content is not freezing: status and the stage
155
+ // disclosure used to read the live files, so editing a rule silently changed what
156
+ // a running task was told to follow — the instability the snapshot exists to stop.
157
+ const f = await atDevelopmentWithProjectRule()
158
+ const before = await f.disclosed()
159
+ assert.equal(before.mine?.content, 'ORIGINAL RULE', 'the frozen body is what gets disclosed')
160
+
161
+ f.records.set(f.rule(), 'CHANGED RULE')
162
+ const after = await f.disclosed()
163
+ assert.equal(after.mine?.content, 'ORIGINAL RULE',
164
+ 'an edit to the source must not change what the running task is told to follow')
165
+ assert.ok((after.status.stale_source_rules ?? []).some(r => r.includes('edited')),
166
+ 'the drift is reported so it is visible rather than silently authoritative')
167
+ })
168
+
169
+ test('freeze: a running task survives its source rule being deleted', async () => {
170
+ // The frozen copy exists precisely so a deleted file is not a missing
171
+ // constraint. Reporting it as missing would be wrong: the task has the body.
172
+ const f = await atDevelopmentWithProjectRule()
173
+ f.records.delete(f.rule())
174
+ const { status, mine } = await f.disclosed()
175
+ assert.equal(mine?.content, 'ORIGINAL RULE', 'the frozen body is still in force')
176
+ assert.deepEqual(status.missing_rules, [],
177
+ 'a frozen rule is not missing; the task already holds its content')
178
+ assert.ok((status.stale_source_rules ?? []).some(r => r.includes('deleted')),
179
+ 'the deletion is reported as drift')
180
+ })
181
+
182
+ test('freeze: an unfrozen task falls back to live resolution and reports a missing file', async () => {
183
+ // Tasks created before freezing have no snapshot. They must keep working, and a
184
+ // deleted rule must be reported as missing rather than silently unbound — the
185
+ // two paths are distinguishable and neither silently degrades into the other.
186
+ const f = await atDevelopmentWithProjectRule()
187
+ const state = JSON.parse(f.records.get(join(f.cwd, '.dsh/task-FZ-2.json')))
188
+ delete state.flow.resources
189
+ f.records.set(join(f.cwd, '.dsh/task-FZ-2.json'), JSON.stringify(state))
190
+
191
+ const live = await f.disclosed()
192
+ assert.equal(live.mine?.content, 'ORIGINAL RULE', 'an unfrozen task reads the live file')
193
+
194
+ f.records.delete(f.rule())
195
+ const { status, mine } = await f.disclosed()
196
+ assert.equal(mine, undefined, 'with no snapshot and no file, the rule cannot resolve')
197
+ assert.ok((status.missing_rules ?? []).includes('project:mine'),
198
+ 'it is reported as missing so the gate can block on it')
199
+ })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The commit hook must agree with the tool about the same commit.
3
+ *
4
+ * The assessment requires one set of semantics shared by `status`, the tool's own
5
+ * gates and the Git hook, rather than three implementations that drift. The hook
6
+ * is built from the same engine source, so this checks the built artefact rather
7
+ * than the source relationship.
8
+ */
9
+ import { readFileSync } from 'node:fs'
10
+ import { resolveFlow } from './lib/workflows.js'
11
+ import { validateCommitMessage, commitCheckpoint, verificationBlockers } from './lib/engine.js'
12
+
13
+ let bad = 0
14
+ const check = (label, ok, detail = '') => {
15
+ console.log(' ' + (ok ? '✅' : '❌') + ' ' + label + (detail ? ' — ' + detail : ''))
16
+ if (!ok) bad++
17
+ }
18
+
19
+ console.log('=== hook 与工具共享判定 ===')
20
+ const hook = readFileSync('hooks/commit-msg', 'utf8')
21
+ for (const fn of ['validateCommitMessage', 'commitCheckpoint', 'verificationHeldStages', 'verificationBlockers']) {
22
+ check('hook 内置 ' + fn, hook.includes(fn), hook.includes(fn) ? '' : 'hook 与工具语义会漂移')
23
+ }
24
+
25
+ console.log('')
26
+ console.log('=== 每种标签形状都能通过它自己预设的校验 ===')
27
+ for (const id of ['standard', 'agile', 'minimal']) {
28
+ const cfg = resolveFlow(id, {}).config
29
+ const labels = id === 'agile' ? ['TASK', 'T1', 'T2'] : ['TASK']
30
+ for (const label of labels) {
31
+ const message = `【T-1】【${label}】did the thing`
32
+ const r = validateCommitMessage(message, cfg)
33
+ check(`${id}「${label}」被接受`, r.ok, r.ok ? '' : (r.errors ?? []).join('; '))
34
+ }
35
+ }
36
+
37
+ console.log('')
38
+ console.log('=== 提交许可与验证要求一致(三个预设)===')
39
+ for (const id of ['standard', 'agile', 'minimal']) {
40
+ const cfg = resolveFlow(id, {}).config
41
+ const terminal = cfg.stages.filter(s => !cfg.transitions.some(t => t.from === s))[0]
42
+ const failing = {
43
+ stage: terminal, execution_version: 1,
44
+ items: [{ id: 'A', status: 'done', review: { spec: { outcome: 'pass' }, quality: { outcome: 'pass' } } }],
45
+ verification: { passed: false, evidence: [] }, commits: [],
46
+ }
47
+ const vb = verificationBlockers(failing, cfg)
48
+ const cp = commitCheckpoint(failing, cfg)
49
+ // 需要验证的流程:失败必须同时阻止提交;不需要验证的流程:两者都不应因验证而阻止
50
+ const requires = cfg.transitions.some(t => (t.requires ?? []).includes('verified'))
51
+ if (requires) {
52
+ check(`${id} 验证失败阻止提交`, vb.length > 0 && cp.allowed === false, `blockers=${vb.length} allowed=${cp.allowed}`)
53
+ } else {
54
+ check(`${id} 不发明验证要求`, vb.length === 0, JSON.stringify(vb))
55
+ }
56
+ }
57
+
58
+ console.log('')
59
+ console.log(bad === 0 ? '✅ hook 与工具语义一致' : `❌ ${bad} 项不一致`)
60
+ process.exit(bad === 0 ? 0 : 1)
package/.hook-test.mjs CHANGED
@@ -100,10 +100,15 @@ function approvedTask(overrides = {}) {
100
100
  }
101
101
  }
102
102
 
103
- /** Resolve the standard preset's config the same way the hook does. */
103
+ /**
104
+ * Resolve the standard preset's config the way a real project has it: the skeleton
105
+ * plus the shipped recommendation, adopted. The hook reads the project's config
106
+ * from disk, so a project that adopted the recommendation is what these tests
107
+ * describe — the bare skeleton has no commit rule or artifacts to check.
108
+ */
104
109
  async function standardConfig() {
105
- const { resolveFlow } = await import('./lib/workflows.js')
106
- const resolved = resolveFlow('standard')
110
+ const { resolveFlow, adoptRecommendation } = await import('./lib/workflows.js')
111
+ const resolved = resolveFlow('standard', adoptRecommendation('standard'))
107
112
  assert.equal(resolved.ok, true, 'the built-in standard preset resolves')
108
113
  return resolved.config
109
114
  }