@godv61/dsh-task-engine 0.20.0 → 0.22.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 +246 -0
- package/README.md +3 -1
- package/docs/manual.html +11 -7
- package/hooks/commit-msg +45 -0
- package/lib/client.js.map +2 -2
- package/lib/dev-task.d.ts +7 -0
- package/lib/dev-task.js +157 -8
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +19 -0
- package/lib/engine.js +2 -0
- package/lib/engine.js.map +1 -1
- package/lib/hook.js +30 -0
- package/lib/hook.js.map +1 -1
- package/lib/project.d.ts +66 -0
- package/lib/project.js +128 -0
- package/lib/project.js.map +1 -0
- package/lib/snapshot.d.ts +21 -0
- package/lib/snapshot.js +26 -0
- package/lib/snapshot.js.map +1 -0
- package/package.json +1 -1
- package/skills/code-verify/SKILL.md +1 -1
package/.p0-test.mjs
CHANGED
|
@@ -289,4 +289,250 @@ await assertThrows(
|
|
|
289
289
|
assert(controller.includes('overwrite'), 'writeInit request carries an overwrite field')
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
// ── 21. 0.21-A: project adapter — type detection, root discovery, default verify commands ──
|
|
293
|
+
const project = await import('./lib/project.js')
|
|
294
|
+
|
|
295
|
+
function memProbe(files) {
|
|
296
|
+
return {
|
|
297
|
+
async read(dir, relPath) {
|
|
298
|
+
const key = `${dir.replace(/\\/g, '/')}/${relPath}`.replace(/\/+/g, '/')
|
|
299
|
+
return files[key]
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
{
|
|
305
|
+
const probe = memProbe({ '/repo/package.json': '{}' })
|
|
306
|
+
assert(await project.detectType(probe, '/repo') === 'node', 'node marker resolves to node')
|
|
307
|
+
assert(await project.detectRoot(probe, '/repo/src/nested') === '/repo', 'root discovery climbs to the package.json directory')
|
|
308
|
+
}
|
|
309
|
+
{
|
|
310
|
+
const probe = memProbe({ '/repo/pom.xml': '<project/>' })
|
|
311
|
+
assert(await project.detectType(probe, '/repo') === 'java', 'pom.xml resolves to java')
|
|
312
|
+
}
|
|
313
|
+
{
|
|
314
|
+
const probe = memProbe({ '/repo/go.mod': 'module x' })
|
|
315
|
+
assert(await project.detectType(probe, '/repo') === 'go', 'go.mod resolves to go')
|
|
316
|
+
}
|
|
317
|
+
{
|
|
318
|
+
const probe = memProbe({ '/repo/Cargo.toml': '[package]' })
|
|
319
|
+
assert(await project.detectType(probe, '/repo') === 'rust', 'Cargo.toml resolves to rust')
|
|
320
|
+
}
|
|
321
|
+
{
|
|
322
|
+
const probe = memProbe({ '/repo/requirements.txt': 'flask' })
|
|
323
|
+
assert(await project.detectType(probe, '/repo') === 'python', 'requirements.txt resolves to python')
|
|
324
|
+
}
|
|
325
|
+
{
|
|
326
|
+
const probe = memProbe({})
|
|
327
|
+
assert(await project.detectType(probe, '/empty') === 'unknown', 'empty tree reports unknown type')
|
|
328
|
+
assert(await project.detectRoot(probe, '/empty/deep/dir') === '/empty/deep/dir', 'root discovery falls back to the start directory')
|
|
329
|
+
}
|
|
330
|
+
{
|
|
331
|
+
const probe = memProbe({ '/repo/package.json': '{}', '/repo/pyproject.toml': '[tool]' })
|
|
332
|
+
assert(await project.detectType(probe, '/repo') === 'unknown', 'mixed tree reports unknown, never a silent single-language guess')
|
|
333
|
+
}
|
|
334
|
+
{
|
|
335
|
+
const probe = memProbe({ '/repo/.git/HEAD': 'ref: refs/heads/main' })
|
|
336
|
+
assert(await project.detectRoot(probe, '/repo/sub/module') === '/repo', 'root discovery stops at the git repository')
|
|
337
|
+
}
|
|
338
|
+
{
|
|
339
|
+
assert(project.defaultVerifyCommand('node') === 'npm test', 'node default verify command')
|
|
340
|
+
assert(project.defaultVerifyCommand('java') === 'mvn -q test', 'java default verify command')
|
|
341
|
+
assert(project.defaultVerifyCommand('go') === 'go test ./...', 'go default verify command')
|
|
342
|
+
assert(project.defaultVerifyCommand('unknown') === undefined, 'unknown type has no default command')
|
|
343
|
+
}
|
|
344
|
+
{
|
|
345
|
+
const probe = memProbe({ '/repo/AGENTS.md': '# a', '/repo/CLAUDE.md': '# c' })
|
|
346
|
+
const present = await project.presentGovernanceFiles(probe, '/repo')
|
|
347
|
+
assert(present.includes('AGENTS.md') && present.includes('CLAUDE.md'), 'governance files are recognized as present')
|
|
348
|
+
assert(!present.includes('.cursorrules'), 'absent governance files are not listed')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── 22. 0.21-C/D/E: out-of-root write guard, hook risk policy, project binding dirs ──
|
|
352
|
+
{
|
|
353
|
+
const { assertInsideRoot } = await import('./lib/dev-task.js')
|
|
354
|
+
assertInsideRoot('/repo', '/repo/sub', 'AGENTS.md')
|
|
355
|
+
assertInsideRoot('/repo/sub', '/repo/sub', 'AGENTS.md')
|
|
356
|
+
await assertThrows(
|
|
357
|
+
() => assertInsideRoot('/repo', '/repo', '../outside.txt'),
|
|
358
|
+
'escapes the project root',
|
|
359
|
+
'init write escaping the root is rejected',
|
|
360
|
+
)
|
|
361
|
+
await assertThrows(
|
|
362
|
+
() => assertInsideRoot('/repo', '/repo', 'C:/elsewhere/x.txt'),
|
|
363
|
+
'escapes the project root',
|
|
364
|
+
'absolute target outside the root is rejected',
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
{
|
|
368
|
+
const hook = readFileSync('./hooks/commit-msg', 'utf8')
|
|
369
|
+
assert(hook.includes('sensitive_paths'), 'hook bundles the risk policy table (single source)')
|
|
370
|
+
assert(hook.includes('risk_level === "high_risk"') || hook.includes('risk_level === \'high_risk\''), 'hook requires high_risk before touching sensitive paths')
|
|
371
|
+
}
|
|
372
|
+
{
|
|
373
|
+
const probe = {
|
|
374
|
+
async read(dir, relPath) {
|
|
375
|
+
const key = `${dir}/${relPath}`.replace(/\/+/g, '/')
|
|
376
|
+
return files[key]
|
|
377
|
+
},
|
|
378
|
+
async list(dir, relPath) {
|
|
379
|
+
const prefix = `${dir}/${relPath}`.replace(/\/+/g, '/')
|
|
380
|
+
return Object.keys(files).filter(k => k.startsWith(prefix + '/')).map(k => k.slice(prefix.length + 1).split('/')[0])
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
const files = {
|
|
384
|
+
'/repo/.dsh/rules/api-conventions.md': '# r',
|
|
385
|
+
'/repo/.dsh/rules/README.md': '# meta',
|
|
386
|
+
'/repo/.dsh/skills/java-dev/SKILL.md': '# s',
|
|
387
|
+
'/repo/.dsh/skills/empty/': '# no skill file',
|
|
388
|
+
}
|
|
389
|
+
const rules = await project.listProjectRules(probe, '/repo')
|
|
390
|
+
assert(rules.length === 2 && rules.includes('api-conventions'), 'project rules list from .dsh/rules/*.md')
|
|
391
|
+
const skills = await project.listProjectSkills(probe, '/repo')
|
|
392
|
+
assert(skills.length === 1 && skills.includes('java-dev'), 'project skills list only dirs carrying SKILL.md')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── 23. 0.22-A: snapshot hash integrity ────────────────────────────────────
|
|
396
|
+
{
|
|
397
|
+
const { hashConfig } = await import('./lib/snapshot.js')
|
|
398
|
+
const config = FLOW_PRESETS.standard.config
|
|
399
|
+
const h1 = hashConfig(config)
|
|
400
|
+
const h2 = hashConfig(JSON.parse(JSON.stringify(config)))
|
|
401
|
+
assert(h1 === h2 && /^[0-9a-f]{64}$/.test(h1), 'hashConfig is a deterministic sha256')
|
|
402
|
+
const tampered = JSON.parse(JSON.stringify(config))
|
|
403
|
+
tampered.commit.file_scope = false
|
|
404
|
+
assert(hashConfig(tampered) !== h1, 'editing the frozen config changes the hash')
|
|
405
|
+
}
|
|
406
|
+
{
|
|
407
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
|
|
408
|
+
const exe = await registered(fs)
|
|
409
|
+
await exe({ operation: 'create', task_id: 'HASH-1', title: 'x', branch: 'main' }, EXEC)
|
|
410
|
+
const record = JSON.parse(fs._files.get('.dsh/task-HASH-1.json'))
|
|
411
|
+
record.flow.config.commit.file_scope = false
|
|
412
|
+
fs._files.set('.dsh/task-HASH-1.json', JSON.stringify(record))
|
|
413
|
+
await assertThrows(
|
|
414
|
+
() => exe({ operation: 'status', task_id: 'HASH-1' }, EXEC),
|
|
415
|
+
'hash mismatch',
|
|
416
|
+
'status on a tampered snapshot is rejected',
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
{
|
|
420
|
+
const hook = readFileSync('./hooks/commit-msg', 'utf8')
|
|
421
|
+
assert(hook.includes('hashConfig'), 'hook reuses the single hashConfig source')
|
|
422
|
+
assert(hook.includes('sha256') || hook.includes('createHash'), 'hook bundles the sha256 snapshot check')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── 24. 0.22-B: installed-hook integrity check ─────────────────────────────
|
|
426
|
+
{
|
|
427
|
+
const fs = makeFs({})
|
|
428
|
+
const exe = await registered(fs)
|
|
429
|
+
assert((await exe({ operation: 'verify_hook' }, EXEC)).includes('run install_hook'), 'verify_hook reports an absent hook')
|
|
430
|
+
await exe({ operation: 'install_hook' }, EXEC)
|
|
431
|
+
assert((await exe({ operation: 'verify_hook' }, EXEC)).includes('integrity OK'), 'verify_hook passes on the bundled hook')
|
|
432
|
+
fs._files.set('.git/hooks/commit-msg', fs._files.get('.git/hooks/commit-msg') + '\n// tampered')
|
|
433
|
+
await assertThrows(
|
|
434
|
+
() => exe({ operation: 'verify_hook' }, EXEC),
|
|
435
|
+
'integrity FAILED',
|
|
436
|
+
'verify_hook rejects a tampered hook',
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── 25. 0.22-B: risk downgrade approval + bindings fingerprint ─────────────
|
|
441
|
+
{
|
|
442
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
|
|
443
|
+
const exe = await registered(fs)
|
|
444
|
+
await exe({ operation: 'create', task_id: 'RISK-1', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
|
|
445
|
+
await assertThrows(
|
|
446
|
+
() => exe({ operation: 'set_risk', task_id: 'RISK-1', risk_level: 'standard' }, EXEC),
|
|
447
|
+
'approval',
|
|
448
|
+
'downgrade without an approval service is rejected',
|
|
449
|
+
)
|
|
450
|
+
assert((await exe({ operation: 'set_risk', task_id: 'RISK-1', risk_level: 'high_risk' }, EXEC)).includes('already'),
|
|
451
|
+
'same-risk set_risk is a no-op')
|
|
452
|
+
}
|
|
453
|
+
{
|
|
454
|
+
const approval = { async request() { return 'allowed-once' } }
|
|
455
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
|
|
456
|
+
const exe = await registered(fs, approval)
|
|
457
|
+
await exe({ operation: 'create', task_id: 'RISK-2', title: 'x', branch: 'main', risk_level: 'high_risk' }, EXEC)
|
|
458
|
+
assert((await exe({ operation: 'set_risk', task_id: 'RISK-2', risk_level: 'standard' }, EXEC)).includes('risk_level set to standard'),
|
|
459
|
+
'approved downgrade succeeds')
|
|
460
|
+
const status = JSON.parse(await exe({ operation: 'status', task_id: 'RISK-2' }, EXEC))
|
|
461
|
+
assert(status.risk_level === 'standard' && status.risk_downgrades.length === 1,
|
|
462
|
+
'downgrade is recorded in the audit trail')
|
|
463
|
+
}
|
|
464
|
+
{
|
|
465
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
|
|
466
|
+
const exe = await registered(fs)
|
|
467
|
+
await exe({ operation: 'create', task_id: 'FP-1', title: 'x', branch: 'main' }, EXEC)
|
|
468
|
+
const status = JSON.parse(await exe({ operation: 'status', task_id: 'FP-1' }, EXEC))
|
|
469
|
+
assert(status.bindings_drift === undefined, 'fresh task shows no bindings drift')
|
|
470
|
+
const record = JSON.parse(fs._files.get('.dsh/task-FP-1.json'))
|
|
471
|
+
assert(typeof record.bindings_fingerprint === 'string' && record.bindings_fingerprint.length === 64,
|
|
472
|
+
'create records the bundled-rules fingerprint')
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── 26. 0.22-C: multi-language verify command chain + parallel tasks ────────
|
|
476
|
+
{
|
|
477
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard', verify_command: 'mvn test' }) })
|
|
478
|
+
const exe = await registered(fs)
|
|
479
|
+
await exe({ operation: 'create', task_id: 'V-1', title: 'x', branch: 'main' }, EXEC)
|
|
480
|
+
await assertThrows(
|
|
481
|
+
() => exe({ operation: 'verify', task_id: 'V-1' }, EXEC),
|
|
482
|
+
'shell',
|
|
483
|
+
'verify falls back to the eng.json command and fails loud without a shell service',
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
{
|
|
487
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
|
|
488
|
+
const exe = await registered(fs)
|
|
489
|
+
await exe({ operation: 'create', task_id: 'V-2', title: 'x', branch: 'main' }, EXEC)
|
|
490
|
+
await assertThrows(
|
|
491
|
+
() => exe({ operation: 'verify', task_id: 'V-2' }, EXEC),
|
|
492
|
+
'shell',
|
|
493
|
+
'verify falls back to the language default (node → npm test) and fails loud without a shell service',
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
{
|
|
497
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
|
|
498
|
+
const exe = await registered(fs)
|
|
499
|
+
await exe({ operation: 'create', task_id: 'V-3', title: 'x', branch: 'main' }, EXEC)
|
|
500
|
+
assert((await exe({ operation: 'verify', task_id: 'V-3', passed: true }, EXEC)).includes('ok'),
|
|
501
|
+
'unknown-type verify keeps the legacy self-reported path')
|
|
502
|
+
}
|
|
503
|
+
{
|
|
504
|
+
const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
|
|
505
|
+
const exe = await registered(fs)
|
|
506
|
+
await exe({ operation: 'create', task_id: 'A-1', title: 'x', branch: 'main' }, EXEC)
|
|
507
|
+
await exe({ operation: 'create', task_id: 'B-2', title: 'y', branch: 'main' }, EXEC)
|
|
508
|
+
await exe({ operation: 'scope', task_id: 'A-1', files: ['src/a.ts'] }, EXEC)
|
|
509
|
+
const a = JSON.parse(await exe({ operation: 'status', task_id: 'A-1' }, EXEC))
|
|
510
|
+
const b = JSON.parse(await exe({ operation: 'status', task_id: 'B-2' }, EXEC))
|
|
511
|
+
assert(a.files.length === 1 && b.files.length === 0, 'two tasks in one repo advance independently')
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── 27. 9.4: init apply validates the expected hash ─────────────────────────
|
|
515
|
+
{
|
|
516
|
+
const fs = makeFs({})
|
|
517
|
+
const exe = await registered(fs)
|
|
518
|
+
const propose = await exe({ operation: 'init', phase: 'propose', content: 'hello' }, EXEC)
|
|
519
|
+
const match = /content hash: ([0-9a-f]{64})/.exec(propose)
|
|
520
|
+
assert(match !== null, 'propose returns the content hash')
|
|
521
|
+
await assertThrows(
|
|
522
|
+
() => exe({ operation: 'init', phase: 'apply', content: 'hello', expected_hash: '0'.repeat(64) }, EXEC),
|
|
523
|
+
'expected_hash mismatch',
|
|
524
|
+
'apply with a mismatched hash is rejected',
|
|
525
|
+
)
|
|
526
|
+
assert((await exe({ operation: 'init', phase: 'apply', content: 'hello', expected_hash: match[1] }, EXEC)).includes('wrote'),
|
|
527
|
+
'apply with the matching hash writes the file')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── 28. risk policy excludes project binding dirs ───────────────────────────
|
|
531
|
+
{
|
|
532
|
+
const policy = project.DEFAULT_RISK_POLICY
|
|
533
|
+
assert(!policy.sensitive_paths.includes('.dsh'),
|
|
534
|
+
'project binding dir (.dsh/rules, .dsh/skills) stays committable by normal tasks')
|
|
535
|
+
assert(policy.sensitive_paths.includes('.env'), 'env files remain sensitive')
|
|
536
|
+
}
|
|
537
|
+
|
|
292
538
|
console.log(`\nP0 acceptance: ${passed} checks passed`)
|
package/README.md
CHANGED
|
@@ -175,4 +175,6 @@ dsh plugin --profile <name> add .
|
|
|
175
175
|
- **工作台项目初始化(0.19.0)**:工作台新增置顶的「项目初始化」标签页——加载展示项目根 `AGENTS.md`、一键让 AI 扫描项目生成草稿(预览后再确认写回)、支持手动编辑与覆盖;Host 控制器新增 `readInit`/`writeInit`/`generateInit` 三个 Remote,`generateInit` 通过 `ctx.llm` + 默认模型在 Host 端直接生成,复用 `dev_task init` 的 200 行硬约束。
|
|
176
176
|
- **使用手册跟进(0.19.1)**:随包发布的 `docs/manual.html` 补上工作台「项目初始化」(默认置顶标签页、项目根自动发现、AI 生成 150 秒超时 + 覆盖需人工确认),第 7 节标签页从「三个」改为「五个」;安装章节补全 dsh CLI(非源码)安装方式与 pnpm 前置、`dsh plugin add` 的挂载机制;删除顶层已废弃的 `USER_GUIDE.html`(v0.9.1、无引用、不随包发布),README 目录结构描述同步为五标签页。
|
|
177
177
|
- **审计修复(0.19.2)**:补齐七项——① 提交钩子 `stagedFiles` 用 `core.quotePath=false` + `-z` 按 NUL 拆分(中文文件名不再被八进制转义误拒)并补 `D`(删除范围外文件也被拦);② 提交消息第一段改为 task id、钩子按 id 精确定位任务(不再按分支/mtime 猜,同分支多任务不再锁错);③ artifact id 全流程唯一 + `record` 校验产物属于当前阶段(堵越阶段复用);④ 高风险验证证据 `trim()` 后须非空(`evidence:[""]` 不再通过);⑤ README/手册加「诚实边界」,明说验证/评审/实施项是模型自报、需人工或 CI 兜底;⑥ 文档修正优先级(内置 > 用户 > 项目、内置不可覆盖)与 enable 措辞,`.dsh/task-*.json`/`eng.json` 豁免文件范围门;⑦ 提交钩子改用任务快照的 frozen 配置校验(对抗任务执行中改流程导致的配置漂移);⑧ 工作台 `writeInit` 对齐 `init` 保护(已有 `AGENTS.md` 时需显式 overwrite + 前端确认才覆盖);⑨ `.p0-test.mjs` 纳入发布包,装包后 `npm test` 可用。新增回归测试。
|
|
178
|
-
- **安全闭环(0.20.0)**:① 验证改真实命令回执——`dev_task verify` 新增 `command` 入参,引擎通过宿主 shell 服务真实运行该命令并落 `VerificationReceipt`(命令 / 退出码 / 超时 / 中止 / 起止时间 / stdout / stderr);`high_risk` 任务的 `verified` 门改为要求回执 `exit_code === 0` 且非超时 / 中止,纯文本 `passed` 声明不再放行(常规风险仍可用 `passed` + `evidence` 文本);② 文件范围检查补 `T` + 改用 `--name-status`——`--diff-filter=ACMRDT` 纳入类型变换,提交状态随范围拒绝一并报出,删除 / 类型变换 / 重命名的旧·新路径都受范围检查;③ 提交钩子改为从 `engine.ts` / `workflows.ts` 单一源打包生成(`build-hook.mjs`),彻底消除手写镜像漂移,未知流程在钩子侧同样 fail-closed(不再静默回退 `standard`)。
|
|
178
|
+
- **安全闭环(0.20.0)**:① 验证改真实命令回执——`dev_task verify` 新增 `command` 入参,引擎通过宿主 shell 服务真实运行该命令并落 `VerificationReceipt`(命令 / 退出码 / 超时 / 中止 / 起止时间 / stdout / stderr);`high_risk` 任务的 `verified` 门改为要求回执 `exit_code === 0` 且非超时 / 中止,纯文本 `passed` 声明不再放行(常规风险仍可用 `passed` + `evidence` 文本);② 文件范围检查补 `T` + 改用 `--name-status`——`--diff-filter=ACMRDT` 纳入类型变换,提交状态随范围拒绝一并报出,删除 / 类型变换 / 重命名的旧·新路径都受范围检查;③ 提交钩子改为从 `engine.ts` / `workflows.ts` 单一源打包生成(`build-hook.mjs`),彻底消除手写镜像漂移,未知流程在钩子侧同样 fail-closed(不再静默回退 `standard`)。
|
|
179
|
+
- **通用项目适配(0.21.0)**:① 新增项目适配层 `src/project.ts`——按特征文件识别 Node / Java / Python / Go / Rust 类型,从 workspace 向上发现项目根(`.git` / `.dsh` / 语言特征文件);② 各语言默认验证命令(`npm test` / `mvn -q test` / `python -m pytest` / `go test ./...` / `cargo test`),`verify` 不带 `command` 时按 `.dsh/eng.json` 的 `verify_command` → 语言默认链自动跑真实命令;③ 治理文件识别扩展至 `AGENTS.md`、`CLAUDE.md`、`.cursorrules`,`init inspect` 一并报告治理文件、项目根、语言栈与项目级 skill/rule 目录(约定 `.dsh/rules/*.md`、`.dsh/skills/<name>/SKILL.md`);④ 提交钩子加风险策略——触及敏感路径(`.git` / `.env` / credentials / secrets;`.dsh` 的任务记录与流程配置由快照 hash 与豁免保护)要求任务为 `high_risk` 且验证有真实命令回执,否则拒绝;⑤ 初始化写入越界保护——`init` / `create` 的写入目标必须落在项目根内;任务记录新增 `root` / `project_type` 字段。
|
|
180
|
+
- **审计和发布质量(0.22.0)**:① 流程快照 hash——任务创建时固化 `config` 的 SHA-256,工具与提交钩子读任务记录时校验,被手改的快照一律拒绝继续;② 提交钩子完整性检测——新增 `dev_task verify_hook`,比对 `.git/hooks/commit-msg` 与内置门禁的 hash,被替换/篡改立即报错;③ 风险降级审批——新增 `set_risk`,`high_risk → standard` 必须人工批准并落 `risk_downgrades` 审计记录;④ 验证回执绑定项目根——receipt 记录 `root`,与任务 workspace 绑定;⑤ 内置规则指纹锁定——创建时固化内置规则内容指纹,包升级后 `status` 报 `bindings_drift` 而非静默换规则;⑥ 多项目 / 多语言 / 多任务测试矩阵补强。
|
package/docs/manual.html
CHANGED
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
</div>
|
|
82
82
|
<p>三样东西分工明确:<strong>流程预设</strong>决定「走哪条流水线、有哪些守卫」,<strong>Skill</strong> 决定「这一站做什么」,<strong>Rule</strong> 决定「这一站守什么」。任务状态单独落盘,负责「跨会话恢复到哪一步」。</p>
|
|
83
83
|
<div class="callout warn">
|
|
84
|
-
<p><strong>诚实边界:</strong
|
|
84
|
+
<p><strong>诚实边界:</strong>阶段流转、提交格式、文件范围、消息里的任务绑定、流程快照 hash、钩子完整性、敏感路径风险策略是<b>代码硬校验</b>(不匹配直接拒绝);<b>高风险任务的「验证通过」也是真实命令回执</b>——引擎实际运行 <code>verify</code> 命令、取退出码(<code>exit_code === 0</code> 且非超时/中止)才放行,不是模型自报。仍属模型自报、需人工或 CI 兜底的是:常规风险的验证声明、「评审通过」「实施项完成」,以及回执命令本身的覆盖面。只有「需求确认」「方案确认」两扇门由人工批准点亮,风险降级(high_risk → standard)也由人工批准。</p>
|
|
85
85
|
</div>
|
|
86
86
|
</section>
|
|
87
87
|
|
|
@@ -227,14 +227,14 @@ pnpm install && pnpm run build && pnpm dsh web</code></pre>
|
|
|
227
227
|
|
|
228
228
|
<h3>对话式 init</h3>
|
|
229
229
|
<ol>
|
|
230
|
-
<li><b>inspect</b>:对 AI 说「帮我初始化这个项目」;AI 读现有 <code>AGENTS.md</code>(若有)返回,没有则扫目录结构、技术栈、构建 /
|
|
231
|
-
<li><b>propose</b>:AI 写成 <code>AGENTS.md</code> 草稿,此步<b>只预览、不落盘</b>,同样硬校验<b>行数 ≤ 200 行</b
|
|
232
|
-
<li><b>apply</b>:落盘。首次创建直接写入;覆盖已有 <code>AGENTS.md</code> 必须带 <code>overwrite</code> 且<b>经人工批准</b
|
|
230
|
+
<li><b>inspect</b>:对 AI 说「帮我初始化这个项目」;AI 读现有 <code>AGENTS.md</code>(若有)返回,没有则扫目录结构、技术栈、构建 / 运行命令、约定与红线。同时报告:项目根(自动从 workspace 向上发现)、语言栈(Node / Java / Python / Go / Rust)与默认验证命令、其它治理文件(<code>CLAUDE.md</code>、<code>.cursorrules</code>——init 只管理 AGENTS.md,不覆盖它们)、项目级 skill/rule 目录(<code>.dsh/rules/*.md</code>、<code>.dsh/skills/<name>/SKILL.md</code>)。</li>
|
|
231
|
+
<li><b>propose</b>:AI 写成 <code>AGENTS.md</code> 草稿,此步<b>只预览、不落盘</b>,同样硬校验<b>行数 ≤ 200 行</b>,并返回内容 hash。</li>
|
|
232
|
+
<li><b>apply</b>:落盘。首次创建直接写入;覆盖已有 <code>AGENTS.md</code> 必须带 <code>overwrite</code> 且<b>经人工批准</b>;传回 <code>expected_hash</code> 与 propose 的内容 hash 不一致会被拒绝(防止预览与落盘之间内容被换)。写入目标必须在项目根内。</li>
|
|
233
233
|
</ol>
|
|
234
234
|
<pre><code>项目根/
|
|
235
235
|
├─ AGENTS.md ← init 生成,DSH 每会话自动注入
|
|
236
236
|
└─ .dsh/
|
|
237
|
-
├─ eng.json
|
|
237
|
+
├─ eng.json ← flow + verify_command(可覆盖语言默认验证命令)
|
|
238
238
|
├─ skills/ · rules/
|
|
239
239
|
└─ task-*.json</code></pre>
|
|
240
240
|
</section>
|
|
@@ -308,6 +308,7 @@ pnpm install && pnpm run build && pnpm dsh web</code></pre>
|
|
|
308
308
|
<li>差异没变时复用有效证据,不重复跑相同命令。</li>
|
|
309
309
|
<li>无法联调外部系统时必须如实写「未联调」,不能把编译成功说成功能通过。</li>
|
|
310
310
|
<li><code>high_risk</code> 任务过验证门必须跑真实命令回执(引擎运行命令、取退出码,非自报通过)。</li>
|
|
311
|
+
<li><code>verify</code> 不带 <code>command</code> 时引擎自动选命令:<code>.dsh/eng.json</code> 的 <code>verify_command</code> 优先,其次按项目语言用默认(node → <code>npm test</code>、java → <code>mvn -q test</code>、python → <code>python -m pytest</code>、go → <code>go test ./...</code>、rust → <code>cargo test</code>);识别不出语言且未配置时保留自报路径。跑命令依赖宿主 shell 服务,未挂载会明确报错。</li>
|
|
311
312
|
</ul>
|
|
312
313
|
<h3>评审</h3>
|
|
313
314
|
<p>开发阶段每项做「规格 + 质量」两阶段评审,都 pass 才标 done;缺评审或任一阶段 fail 会挡住「开发 → 交付」。</p>
|
|
@@ -323,7 +324,10 @@ pnpm install && pnpm run build && pnpm dsh web</code></pre>
|
|
|
323
324
|
</table>
|
|
324
325
|
</div>
|
|
325
326
|
<p>提交消息要求 <code>【模块】【TASK】做了什么</code>,且只提交任务 <code>files</code> 范围内的文件。要彻底封死模型绕过 <code>dev_task</code> 直接 <code>git commit</code>,给仓库装一道机械钩子:</p>
|
|
326
|
-
<pre><code>dev_task operation=install_hook # 装入 .git/hooks/commit-msg,装上后任何 git commit
|
|
327
|
+
<pre><code>dev_task operation=install_hook # 装入 .git/hooks/commit-msg,装上后任何 git commit 都被同一套规则校验
|
|
328
|
+
dev_task operation=verify_hook # 随时比对安装钩子与内置门禁的 hash,被替换/篡改立即报错</code></pre>
|
|
329
|
+
<p><strong>钩子的额外硬校验(0.21/0.22):</strong>① 任务记录的流程快照带 SHA-256 hash,被手改过的快照一律拒绝提交;② 触及敏感路径(<code>.env</code>、credentials、secrets、<code>.git</code>;<code>.dsh</code> 的任务记录与流程配置由快照 hash 与豁免保护,项目级 rules/skills 属常规内容)的提交,要求任务为 <code>high_risk</code> 且验证有真实命令回执,否则拒绝;③ 删除、类型变换、重命名的旧·新路径都受范围检查。</p>
|
|
330
|
+
<p><strong>风险降级(0.22):</strong><code>set_risk</code> 把 <code>high_risk</code> 降为 <code>standard</code> 必须人工批准,并写入任务的 <code>risk_downgrades</code> 审计记录;降级后高风险回执门不再适用,请谨慎。</p>
|
|
327
331
|
</section>
|
|
328
332
|
|
|
329
333
|
<section id="example">
|
|
@@ -367,7 +371,7 @@ risk_level: high_risk // 涉及鉴权,验证要加证据
|
|
|
367
371
|
<details><summary>一个会话能同时做两个需求吗?</summary><div>一个任务对应一份状态文档、一个分支。新需求保存当前进度后切新分支,切回原分支即可恢复。</div></details>
|
|
368
372
|
<details><summary>提交被拒是怎么回事?</summary><div>通常是四类:没建 dev_task 任务、阶段没到提交检查点、消息格式不符、提交文件不在任务范围内。按拒绝原因修正即可。</div></details>
|
|
369
373
|
<details><summary>「完成」等于上线了吗?</summary><div>不等于。<code>done</code> 只表示本地验证与必要评审通过;上线、合并、发布需人另行决定。</div></details>
|
|
370
|
-
<p class="stamp">当前结构基线:三套流程预设 · 工作台项目初始化(0.19.0)· 最后更新:2026-09-12</p>
|
|
374
|
+
<p class="stamp">当前结构基线:三套流程预设 · 工作台项目初始化(0.19.0)· 通用项目适配(0.21.0)· 审计与发布质量(0.22.0)· 最后更新:2026-09-12</p>
|
|
371
375
|
</section>
|
|
372
376
|
</main>
|
|
373
377
|
</div>
|
package/hooks/commit-msg
CHANGED
|
@@ -240,6 +240,32 @@ function resolveFlow(flow, override) {
|
|
|
240
240
|
return { ok: true, config: mergeBindings(preset2.config, override?.stage_bindings), preset: preset2 };
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
// src/snapshot.ts
|
|
244
|
+
var import_node_crypto = require("node:crypto");
|
|
245
|
+
function hashText(text) {
|
|
246
|
+
return (0, import_node_crypto.createHash)("sha256").update(text).digest("hex");
|
|
247
|
+
}
|
|
248
|
+
function hashConfig(config2) {
|
|
249
|
+
return hashText(JSON.stringify(config2));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/project.ts
|
|
253
|
+
var TYPE_MARKERS = {
|
|
254
|
+
node: ["package.json"],
|
|
255
|
+
java: ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle"],
|
|
256
|
+
python: ["pyproject.toml", "requirements.txt", "setup.py"],
|
|
257
|
+
go: ["go.mod"],
|
|
258
|
+
rust: ["Cargo.toml"]
|
|
259
|
+
};
|
|
260
|
+
var DEFAULT_RISK_POLICY = {
|
|
261
|
+
// `.dsh/task-*.json` and `.dsh/eng.json` are protected by the snapshot hash and
|
|
262
|
+
// the engine-meta exemption, not by this list; `.dsh/rules` / `.dsh/skills`
|
|
263
|
+
// hold regular project content and stay committable by normal tasks.
|
|
264
|
+
sensitive_paths: [".git", ".env", ".credentials.yaml", "credentials", "secrets"],
|
|
265
|
+
risky_operations: ["D", "T"]
|
|
266
|
+
};
|
|
267
|
+
var ALL_MARKERS = Object.values(TYPE_MARKERS).flat();
|
|
268
|
+
|
|
243
269
|
// src/hook.ts
|
|
244
270
|
var messageFile = process.argv[2];
|
|
245
271
|
function refuse(reason) {
|
|
@@ -345,6 +371,16 @@ function extractTaskId(message2) {
|
|
|
345
371
|
const match = /^【([^【】\s]+)】/.exec(message2);
|
|
346
372
|
return match ? match[1] : void 0;
|
|
347
373
|
}
|
|
374
|
+
function isEngineMeta2(file) {
|
|
375
|
+
return /^\.dsh\/(task-[^/]+\.json|eng\.json)$/.test(file);
|
|
376
|
+
}
|
|
377
|
+
function riskyPaths(entries2) {
|
|
378
|
+
return entries2.filter((entry) => !isEngineMeta2(entry.path)).filter((entry) => DEFAULT_RISK_POLICY.sensitive_paths.some((prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}/`))).map((entry) => entry.path);
|
|
379
|
+
}
|
|
380
|
+
function riskVerified(state2) {
|
|
381
|
+
const receipt = state2.verification.receipt;
|
|
382
|
+
return state2.risk_level === "high_risk" && receipt !== void 0 && receipt.exit_code === 0 && !receipt.timed_out && !receipt.aborted;
|
|
383
|
+
}
|
|
348
384
|
function defaultConfig() {
|
|
349
385
|
const resolved = resolveFlow("standard");
|
|
350
386
|
if (!resolved.ok) throw new Error('built-in "standard" preset is missing');
|
|
@@ -402,6 +438,9 @@ if (taskId !== void 0) {
|
|
|
402
438
|
if (!state) {
|
|
403
439
|
refuse("\u6CA1\u6709\u627E\u5230 dev_task \u4EFB\u52A1\u8BB0\u5F55\uFF08.dsh/task-*.json\uFF09\u2014\u2014\u8BF7\u5148 dev_task operation=create \u5EFA\u7ACB\u4EFB\u52A1");
|
|
404
440
|
}
|
|
441
|
+
if (state.flow?.hash !== void 0 && hashConfig(state.flow.config) !== state.flow.hash) {
|
|
442
|
+
refuse(`\u4EFB\u52A1 ${state.id} \u7684\u6D41\u7A0B\u5FEB\u7167 hash \u4E0D\u5339\u914D\u2014\u2014task \u8BB0\u5F55\u5728\u521B\u5EFA\u540E\u88AB\u6539\u52A8\u8FC7\uFF0C\u4FEE\u590D\u6216\u91CD\u5EFA\u4EFB\u52A1\u540E\u518D\u63D0\u4EA4`);
|
|
443
|
+
}
|
|
405
444
|
if (state.flow !== void 0 && state.flow.config !== void 0) {
|
|
406
445
|
config = state.flow.config;
|
|
407
446
|
}
|
|
@@ -425,4 +464,10 @@ if (!scope.ok) {
|
|
|
425
464
|
});
|
|
426
465
|
refuse("\u63D0\u4EA4\u4E86\u4EFB\u52A1\u8303\u56F4\u4E4B\u5916\u7684\u6587\u4EF6: " + described.join(", "));
|
|
427
466
|
}
|
|
467
|
+
var sensitive = riskyPaths(entries);
|
|
468
|
+
if (sensitive.length > 0 && !riskVerified(state)) {
|
|
469
|
+
refuse(
|
|
470
|
+
"\u63D0\u4EA4\u89E6\u53CA\u654F\u611F\u8DEF\u5F84: " + sensitive.join(", ") + " \u2014 \u8FD9\u7C7B\u53D8\u66F4\u8981\u6C42\u4EFB\u52A1\u58F0\u660E\u4E3A high_risk\uFF0C\u4E14\u9A8C\u8BC1\u5FC5\u987B\u662F\u771F\u5B9E\u547D\u4EE4\u56DE\u6267\uFF08exit 0\uFF09"
|
|
471
|
+
);
|
|
472
|
+
}
|
|
428
473
|
process.exit(0);
|