From 2f04caea010404976bf70e7a5a8e93e47175a0d8 Mon Sep 17 00:00:00 2001
From: gongw <294669482@qq.com>
Date: Thu, 5 Nov 2026 12:26:09 +0800
Subject: [PATCH 1/2] fix: recover invalid file scopes (0.40.1)
---
.boundary-fixes-test.mjs | 5 +-
.file-scope-test.mjs | 225 +++++++++++++++++++++++++++++++
README.md | 6 +-
docs/development.md | 2 +-
docs/manual.html | 3 +-
docs/releases/0.40.1.md | 15 +++
package-lock.json | 4 +-
package.json | 7 +-
scripts/verify-package.mjs | 2 +-
skills/code-development/SKILL.md | 2 +
skills/test-validation/SKILL.md | 2 +
src/client/TaskLedger.tsx | 7 +
src/client/remote.ts | 1 +
src/controller.ts | 28 ++--
src/dev-task.ts | 109 +++++++++++----
src/task-files.ts | 51 +++++++
src/task-ledger-document.ts | 2 +
src/task-ledger-types.ts | 1 +
src/task-output.ts | 3 +-
19 files changed, 430 insertions(+), 45 deletions(-)
create mode 100644 .file-scope-test.mjs
create mode 100644 docs/releases/0.40.1.md
create mode 100644 src/task-files.ts
diff --git a/.boundary-fixes-test.mjs b/.boundary-fixes-test.mjs
index 4d6c2e4..1b8fdce 100644
--- a/.boundary-fixes-test.mjs
+++ b/.boundary-fixes-test.mjs
@@ -174,7 +174,10 @@ test('deletion plans reject untracked old content, directories and invalid renam
assert.throws(() => validateDeliveryChanges([{ path: 'a', kind: 'rename', to: 'b' }], ['a']), /both rename paths/)
assert.throws(() => validateDeliveryChanges([{ path: 'a', kind: 'delete' }, { path: 'a', kind: 'delete' }], ['a']), /unique old paths/)
const directory = await fixture(); unlinkSync(join(directory.root, directory.old)); mkdirSync(join(directory.root, directory.old))
- await assert.rejects(directory.plan([{ path: directory.old, kind: 'delete' }]), /not a regular file/)
+ const unchanged = directory.state()
+ await assert.rejects(directory.plan([{ path: directory.old, kind: 'delete' }]),
+ error => error.code === 'INVALID_FILE_SCOPE' && error.path === directory.old)
+ assert.deepEqual(directory.state(), unchanged)
})
test('baseline capture preserves UTF-8 text and rejects binary snapshots', async () => {
for (const body of ['export const 名称 = "旧实现,包含中文"\n', 'binary\0content']) {
diff --git a/.file-scope-test.mjs b/.file-scope-test.mjs
new file mode 100644
index 0000000..117bd93
--- /dev/null
+++ b/.file-scope-test.mjs
@@ -0,0 +1,225 @@
+/** File declarations, readable error projections and controlled recovery against real modules. */
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, lstatSync, rmSync, symlinkSync } from 'node:fs'
+import { join, dirname, resolve, sep } from 'node:path'
+import { tmpdir } from 'node:os'
+import { spawnSync } from 'node:child_process'
+import { runInNewContext } from 'node:vm'
+import { createRequire } from 'node:module'
+import { registerDevTask, scopeFingerprint, verificationInputHashes } from './lib/dev-task.js'
+import { validateTaskFiles } from './lib/task-files.js'
+import Controller from './lib/controller.js'
+import { taskDocument } from './lib/task-ledger-document.js'
+import { confirmedRequirement } from './scripts/fixtures/confirmed-requirement.mjs'
+
+const command = 'node --test --experimental-test-isolation=none --test-reporter=tap example.test.mjs'
+async function fixture(t) {
+ const root = mkdtempSync(join(tmpdir(), 'task-file-scope-'))
+ t.after(() => {
+ assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep + 'task-file-scope-'))
+ rmSync(root, { recursive: true, force: true })
+ })
+ const put = (file, text) => { mkdirSync(dirname(join(root, file)), { recursive: true }); writeFileSync(join(root, file), text) }
+ put('package.json', '{"type":"module"}\n')
+ put('example.mjs', 'export const value = 2\n')
+ put('example.test.mjs', 'import{test}from"node:test";import assert from"node:assert/strict";import{value}from"./example.mjs";test("business value",()=>assert.equal(value,2))\n')
+ const fs = {
+ resolve: async (file, options) => ({ path: resolve(options?.cwd ?? root, file) }),
+ readText: async target => { try { return readFileSync(target.path, 'utf8') } catch (e) { if (e.code !== 'ENOENT') throw e } },
+ lstat: async (file, options) => {
+ try {
+ const info = lstatSync(resolve(options?.cwd ?? root, file))
+ return { type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'symlink', version: info.mtimeMs + ':' + info.size }
+ } catch (e) { if (e.code !== 'ENOENT') throw e }
+ },
+ listDir: async target => readdirSync(target.path, { withFileTypes: true }).map(e => ({ name: e.name,
+ type: e.isSymbolicLink() ? 'symlink' : e.isDirectory() ? 'directory' : 'file' })),
+ writeText: async (target, text) => { mkdirSync(dirname(target.path), { recursive: true }); writeFileSync(target.path, text) },
+ }
+ let execute, shellCalls = 0
+ const ctx = { fs, tools: { register(tool) { execute = tool.execute } }, get(name) {
+ if (name === 'sandboxPolicy') return { resolve: () => ({ mode: 'workspace-write', workspaceRoot: root }) }
+ if (name === 'shell') return { resolve: value => value, run: async spec => {
+ shellCalls++
+ const env = { ...process.env }; delete env.NODE_TEST_CONTEXT
+ const result = spawnSync(process.execPath, spec.command.split(' ').slice(1), { cwd: spec.workdir, encoding: 'utf8', env })
+ return { exitCode: result.status, timedOut: false, aborted: false, stdout: { text: result.stdout ?? '' }, stderr: { text: result.stderr ?? '' },
+ sandbox: { mode: 'workspace-write', denied: false } }
+ } }
+ } }
+ registerDevTask(ctx)
+ const call = args => execute({ task_id: 'SCOPE', ...args }, { agent: { session: { id: 'scope-fixture', header: { cwd: root }, snapshotEvents: () => [] } },
+ signal: new AbortController().signal, callId: 'scope-call' })
+ const ledger = join(root, '.dsh/tasks/SCOPE/task.json')
+ const raw = () => readFileSync(ledger, 'utf8'), state = () => JSON.parse(raw())
+ await call({ operation: 'create', title: 'Scope regression', intent: 'implementation', intent_reason: 'Isolated scope fixture', branch: 'fixture',
+ complexity: 'medium', complexity_reason: 'Local implementation', files: ['example.mjs', 'example.test.mjs'], items: [{ id: 'I1', title: 'complete unit' }] })
+ const initial = confirmedRequirement(state(), put); initial.stage = '代码开发'; writeFileSync(ledger, JSON.stringify(initial))
+ const plan = (files, id = 'checks') => call({ operation: 'plan_verification', verification_group: id, files, command, item_ids: ['I1'] })
+ const view = () => Controller.prototype.readTasks.call({ authorizedPath: async path => path, fs: () => fs }, root)
+ return { root, fs, put, call, raw, state, plan, view, calls: () => shellCalls }
+}
+
+for (const operation of ['create', 'scope', 'plan', 'plan_execution', 'plan_verification']) {
+ test(operation + ' rejects existing directories before any task mutation', async t => {
+ const f = await fixture(t)
+ mkdirSync(join(f.root, 'module'))
+ const before = f.raw()
+ const fields = { files: ['module'], acceptance: ['works'], task_boundary: 'local', execution_reason: 'one complete unit', depends_on: [] }
+ const args = operation === 'create'
+ ? { operation, task_id: 'BAD', title: 'bad scope', intent: 'implementation', intent_reason: 'fixture', branch: 'fixture', complexity: 'medium', complexity_reason: 'fixture', files: ['module'] }
+ : operation === 'plan' ? { operation, plans: [{ id: 'I1', title: 'complete unit', ...fields }] }
+ : operation === 'plan_execution' ? { operation, item_id: 'I1', ...fields }
+ : operation === 'plan_verification' ? { operation, verification_group: 'checks', command, files: ['module'], item_ids: ['I1'] }
+ : { operation, files: ['module'] }
+ await assert.rejects(f.call(args), /INVALID_FILE_SCOPE.*regular files, not directories/)
+ assert.equal(f.raw(), before)
+ assert.equal(await f.fs.lstat('.dsh/tasks/BAD/task.json', { cwd: f.root }), undefined)
+ })
+}
+
+test('all declarations permit concrete new files, including extensionless files and bracketed routes', async t => {
+ const f = await fixture(t)
+ for (const use of ['delivery', 'verification'])
+ assert.deepEqual(await validateTaskFiles(f.fs, ['new/module', 'routes/[slug]/page.tsx'], f.root, 'test', use),
+ ['new/module', 'routes/[slug]/page.tsx'])
+ await f.call({ operation: 'scope', files: ['new/module', 'routes/[slug]/page.tsx'] })
+ await f.plan(['new/module', 'routes/[slug]/page.tsx'])
+})
+
+test('scope and verification reject ledger inputs and traversal; sensitive delivery policy is consistent', async t => {
+ const f = await fixture(t)
+ for (const path of ['.dsh/tasks/SCOPE/task.json', '../outside.txt', '*.mjs']) {
+ const before = f.raw()
+ await assert.rejects(f.call({ operation: 'scope', files: [path] }), /INVALID_FILE_SCOPE/)
+ await assert.rejects(f.plan([path]), /INVALID_FILE_SCOPE/)
+ assert.equal(f.raw(), before)
+ }
+ f.put('.env', 'FIXTURE_ONLY=value\n')
+ await assert.rejects(f.call({ operation: 'scope', files: ['.env'] }), /sensitive delivery path/)
+ // Ordinary configuration may be a read-only test input under existing policy.
+ await f.plan(['example.mjs', 'example.test.mjs', '.env'])
+ const first = await verificationInputHashes(f.fs, f.state(), f.root)
+ f.put('.env', 'FIXTURE_ONLY=changed\n')
+ assert.notEqual((await verificationInputHashes(f.fs, f.state(), f.root)).checks, first.checks)
+})
+
+test('future directory stays visible in status, ledger, transport and Markdown; verification stays blocked', async t => {
+ const f = await fixture(t)
+ await f.plan(['example.mjs', 'example.test.mjs', 'future'])
+ await f.call({ operation: 'verify', verification_group: 'checks' })
+ assert.equal(f.state().verification.passed, true)
+ f.put('future/new.mjs', 'export const added = 1\n')
+ const before = f.raw()
+ const status = JSON.parse(await f.call({ operation: 'status' }))
+ assert.ok(status.file_scope_problems.some(problem => /checks.*future/u.test(problem)))
+ assert.ok(status.evidence_blockers.length)
+ assert.equal(status.commit.allowed, false)
+ const view = await f.view(), task = view.tasks[0]
+ assert.equal(view.tasks.length, 1)
+ assert.equal(view.errors, undefined)
+ assert.equal(task.title, 'Scope regression')
+ assert.equal(task.evidence_status, 'blocked')
+ assert.equal(task.verification_passed, false)
+ assert.equal(task.verification_groups[0].status, 'blocked')
+ assert.match(task.evidence_problems.join('\n'), /checks.*future/)
+ assert.match(taskDocument(task), /文件范围或证据无法核验/)
+ assert.equal(f.raw(), before) // Rendering/status never rewrites original evidence.
+ await assert.rejects(f.call({ operation: 'verify', verification_group: 'checks' }), /INVALID_FILE_SCOPE/)
+ await assert.rejects(f.call({ operation: 'advance', target_stage: '测试' }), /INVALID_FILE_SCOPE/)
+ assert.equal(f.calls(), 1)
+ await f.plan(['example.mjs', 'example.test.mjs', 'future/new.mjs'])
+ assert.equal(f.state().verification.passed, false)
+ await f.call({ operation: 'verify', verification_group: 'checks' })
+ assert.equal((await f.view()).tasks[0].evidence_status, 'valid')
+ assert.equal(f.calls(), 2)
+
+ let registration, contribution
+ runInNewContext(readFileSync('lib/client.js', 'utf8'), { window: { __ModuleLoader__: { load(value) { registration = value } } } })
+ const require = createRequire(import.meta.url)
+ const client = registration.factory(name => name === 'react' || name === 'react/jsx-runtime' ? require(name) : {})
+ const stop = new Error('descriptors captured')
+ await assert.rejects(client.apply({ remote: { $mount: async value => { contribution = value; throw stop } } }), error => error === stop)
+ const method = contribution.descriptors.find(d => d.method === 'readTasks')
+ assert.deepEqual(JSON.parse(JSON.stringify(method.result.create().parse(view))), view)
+})
+
+test('two invalid groups can be repaired individually without faking either receipt', async t => {
+ const f = await fixture(t)
+ await f.plan(['example.mjs', 'example.test.mjs', 'future-a'], 'a')
+ await f.plan(['example.mjs', 'example.test.mjs', 'future-b'], 'b')
+ f.put('future-a/a.mjs', 'a\n'); f.put('future-b/b.mjs', 'b\n')
+ assert.equal((await f.view()).tasks[0].verification_groups.filter(g => g.status === 'blocked').length, 2)
+ await f.plan(['example.mjs', 'example.test.mjs', 'future-a/a.mjs'], 'a')
+ assert.equal(f.state().verification.passed, false)
+ const partial = await f.view()
+ assert.equal(partial.tasks[0].evidence_status, 'blocked')
+ assert.ok(partial.tasks[0].evidence_problems.some(p => /group b/.test(p)))
+ await assert.rejects(f.call({ operation: 'verify', verification_group: 'a' }), /group b.*future-b/)
+ assert.equal(f.calls(), 0)
+ await f.plan(['example.mjs', 'example.test.mjs', 'future-b/b.mjs'], 'b')
+ assert.equal((f.state().verification_runs ?? []).length, 0)
+ await f.call({ operation: 'verify', verification_group: 'a' })
+ await f.call({ operation: 'verify', verification_group: 'b' })
+ assert.equal(f.state().verification.passed, true)
+ assert.equal((await f.view()).tasks[0].evidence_status, 'valid')
+})
+
+test('a declared delivery file that becomes a directory is repairable through scope', async t => {
+ const f = await fixture(t)
+ await f.call({ operation: 'scope', files: ['future'] })
+ f.put('future/real.mjs', 'content\n')
+ assert.equal((await f.view()).tasks[0].evidence_status, 'blocked')
+ const before = f.raw()
+ assert.ok(JSON.parse(await f.call({ operation: 'status' })).file_scope_problems.length)
+ assert.equal(f.raw(), before)
+ await f.call({ operation: 'scope', files: ['future/real.mjs'] })
+ assert.deepEqual(JSON.parse(await f.call({ operation: 'status' })).file_scope_problems, [])
+})
+
+test('legal prototype-like group IDs are not mistaken for file diagnostics', async t => {
+ const f = await fixture(t)
+ await f.plan(['example.mjs', 'example.test.mjs'], 'constructor')
+ const view = await f.view()
+ assert.equal(view.tasks.length, 1)
+ assert.equal(view.tasks[0].evidence_problems, undefined)
+ await f.call({ operation: 'verify', verification_group: 'constructor' })
+ assert.equal((await f.view()).tasks[0].evidence_status, 'valid')
+})
+
+test('linked parents are rejected even for a missing target file', async t => {
+ const f = await fixture(t)
+ mkdirSync(join(f.root, 'actual'))
+ symlinkSync(join(f.root, 'actual'), join(f.root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
+ await assert.rejects(f.call({ operation: 'scope', files: ['linked/missing.mjs'] }), /parent is not an ordinary directory/)
+ await assert.rejects(f.plan(['linked/missing.mjs']), /parent is not an ordinary directory/)
+ await assert.rejects(scopeFingerprint(f.fs, { ...f.state(), files: ['linked/missing.mjs'] }, f.root), /INVALID_FILE_SCOPE/)
+})
+
+test('a missing declared file that becomes a linked file never gets a valid fingerprint', async t => {
+ const f = await fixture(t)
+ await f.plan(['example.mjs', 'example.test.mjs', 'future-link'])
+ mkdirSync(join(f.root, 'actual'))
+ symlinkSync(join(f.root, 'actual'), join(f.root, 'future-link'), process.platform === 'win32' ? 'junction' : 'dir')
+ await assert.rejects(verificationInputHashes(f.fs, f.state(), f.root), /non-regular/)
+ assert.equal((await f.view()).tasks[0].evidence_status, 'blocked')
+})
+
+test('unreadable evidence preserves task identity and never promotes an old passing run', async t => {
+ const f = await fixture(t)
+ await f.plan(['example.mjs', 'example.test.mjs'])
+ await f.call({ operation: 'verify', verification_group: 'checks' })
+ const read = f.fs.readText
+ f.fs.readText = async target => {
+ if (target.path === join(f.root, 'example.mjs')) throw Object.assign(new Error('fixture denied'), { code: 'EACCES' })
+ return read(target)
+ }
+ const view = await f.view()
+ assert.equal(view.tasks.length, 1)
+ assert.equal(view.tasks[0].evidence_status, 'blocked')
+ assert.equal(view.tasks[0].verification_passed, false)
+ assert.match(view.tasks[0].evidence_problems.join('\n'), /example.mjs.*fixture denied/)
+ assert.ok(JSON.parse(await f.call({ operation: 'status' })).file_scope_problems.length)
+ await assert.rejects(f.call({ operation: 'verify', verification_group: 'checks' }), /fixture denied/)
+})
diff --git a/README.md b/README.md
index a1fb4ff..ea07539 100644
--- a/README.md
+++ b/README.md
@@ -37,11 +37,11 @@
不带交付契约的旧台账继续按原快照执行,不自动转换进行中的任务。分析后要求实施时新建关联任务,保留原始调查证据。
-> 插件 `0.40.0` 支持 DSH `0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`,使用 `rc.5` 构建和验证。旧宿主 `0.2.0-rc.2` 继续固定插件 `0.34.1`。
+> 插件 `0.40.1` 支持 DSH `0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`,使用 `rc.5` 构建和验证。旧宿主 `0.2.0-rc.2` 继续固定插件 `0.34.1`。
本次宿主升级的具体影响、验证结果和限制见 [rc.5 适配记录](docs/testing/dsh-rc5-compatibility.md)。
-`0.40.0` 的任务材料、台账面板与流程修复见[版本说明](docs/releases/0.40.0.md)。新任务使用 `.dsh/tasks/<任务标识>/`,不自动扫描或迁移旧目录记录。可选的[产品确认清单 Skill 示例](examples/skills/product-clarification/SKILL.md)供自行安装挂载,不会自动成为内置技能。
+`0.40.1` 修复文件范围校验、异常台账展示与验证组恢复,见[版本说明](docs/releases/0.40.1.md)。`0.40.0` 的任务材料与台账面板介绍见[上一版说明](docs/releases/0.40.0.md)。新任务使用 `.dsh/tasks/<任务标识>/`,不自动扫描或迁移旧目录记录。可选的[产品确认清单 Skill 示例](examples/skills/product-clarification/SKILL.md)供自行安装挂载,不会自动成为内置技能。
一个项目会有许多需求、分支和会话。团队需要复用编码约定,但不同需求不该被迫走完全相同的步骤。
@@ -359,6 +359,8 @@ Sonar 的非秘密配置保存在项目 `.dsh/meta.json`;Token **不会写入
新任务按完整实施项交付代码和对应测试,通过 `plan_verification` 声明前端、后端等必要测试组的命令、输入文件与所属实施项,再用 `verify verification_group=...` 分别取得回执。全部必要组有效才可通过任务验证;同组最新失败不会被历史通过覆盖。输入包括相关测试、构建配置和依赖项,覆盖充分性仍由审核核对。
+`create`、`scope`、实施计划和验证组中的 `files` 都须逐个填写具体普通文件,不能填写目录或通配符;尚未创建的文件可提前登记。路径后来变成目录、链接或无法读取时,台账保留任务并显示错误路径及所属验证组,`status.file_scope_problems` 提供同样的诊断。使用 `scope`、`plan_execution` 或 `plan_verification` 修正对应清单;多个坏组可逐个修正,旧回执保留但不能据此放行。只读测试配置沿用验证输入策略;交付范围拒绝敏感路径和任务台账自身。不会通过忽略目录来宣称测试有效。
+
同一轮缺陷返工可用 `revise revision_kind=defect affected_items=[...]` 指定影响范围;共享输入和下游依赖会扩大失效范围。未受影响的回执和逐项审核保留,任务级审核与 Sonar 重新核对。影响未知时省略范围,完整返工;需求和方案变更仍完整失效。缺少新版需求确认结构的活动任务仅可查看,需新建任务继续;历史回执保留,归档任务继续只读。
开发阶段内也可用 defect 修订重新打开已审核项。`plan_execution.files` 是交付范围,`plan_verification.files` 还包括只读取的配置和依赖;共同配置未修改时不必登记为共同交付。`verify` 返回 `group_result` 表示本组结果,`task_verified` 和兼容字段 `ok` 表示任务整体结果,本组通过时可以先逐项验收。
diff --git a/docs/development.md b/docs/development.md
index 8022dc4..1a611c6 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -4,7 +4,7 @@
插件分为 host 控制器、agent 工具和浏览器工作台。源码在 `src/`,构建结果在 `lib/`,npm 包包含预构建入口、内置 Skill、预设和提交钩子。
-插件 `0.40.0` 以 DSH `0.2.1-rc.5` 构建,明确支持 `rc.3`、`rc.4`、`rc.5`,不自动放行后续预览版。旧宿主 `0.2.0-rc.2` 保持使用插件 `0.34.1`。本版使用声明式 `@deepseek-ai/dsh-agent-preset` 和宿主子代理服务。不再声明旧 `0.1.x` 宿主兼容;旧版文件预设派生测试在缺少旧宿主包时跳过,不能充当当前声明式预设的验收。
+插件 `0.40.1` 以 DSH `0.2.1-rc.5` 构建,明确支持 `rc.3`、`rc.4`、`rc.5`,不自动放行后续预览版。旧宿主 `0.2.0-rc.2` 保持使用插件 `0.34.1`。本版使用声明式 `@deepseek-ai/dsh-agent-preset` 和宿主子代理服务。不再声明旧 `0.1.x` 宿主兼容;旧版文件预设派生测试在缺少旧宿主包时跳过,不能充当当前声明式预设的验收。
边界修复回归在 `.boundary-fixes-test.mjs` 与 `.repair-test.mjs`:使用真实临时 Git 仓库、文件和 Node 测试进程;子代理返回与 Sonar HTTP 为可控适配器,不代表模型语义审核质量或真实 Sonar 服务验收。包黑盒检查也从安装产物运行这些用例。
diff --git a/docs/manual.html b/docs/manual.html
index d202c35..822d244 100644
--- a/docs/manual.html
+++ b/docs/manual.html
@@ -16,7 +16,8 @@
-插件 0.40.0 支持 DSH 0.2.1-rc.3、rc.4、rc.5,以 rc.5 构建。旧宿主 0.2.0-rc.2 继续使用插件 0.34.1。新任务使用 .dsh/tasks/<任务标识>/,不自动扫描或迁移旧目录记录。
+插件 0.40.1 支持 DSH 0.2.1-rc.3、rc.4、rc.5,以 rc.5 构建。旧宿主 0.2.0-rc.2 继续使用插件 0.34.1。新任务使用 .dsh/tasks/<任务标识>/,不自动扫描或迁移旧目录记录。
+文件范围逐个填写具体普通文件,不能填目录或通配符;尚未创建的文件可以提前登记。输入变成目录、链接或无法读取时,台账保留任务并显示错误。使用 scope、plan_execution 或 plan_verification 修正对应清单,取得有效验证后再继续,不能通过忽略路径放行。
DeepSeek Harness · 当前使用手册
DSH Task Engine
diff --git a/docs/releases/0.40.1.md b/docs/releases/0.40.1.md
new file mode 100644
index 0000000..7798014
--- /dev/null
+++ b/docs/releases/0.40.1.md
@@ -0,0 +1,15 @@
+# 0.40.1 — 文件范围校验与任务恢复
+
+发布日期:2026-11-05(Asia/Shanghai)。
+
+当验证计划登记了尚不存在的路径,而该路径随后成为目录时,0.40.0 的指纹计算会抛错,导致任务从台账消失、状态和验证难以恢复。0.40.1 修复了这条故障链路。
+
+- create、scope、plan、plan_execution、plan_verification 统一校验具体普通文件,拒绝目录、链接父路径、越界路径和通配符;继续允许预先登记新文件、无扩展名文件及方括号路由文件。
+- 活动任务的输入无效时仍可查看标题、阶段与进度,台账、Markdown 导出和 status 给出具体路径及所属验证组。
+- 多个无效验证组可以逐个重写清单恢复;旧证据保留,未修正的输入仍阻止放行。
+- verify 和 dispatch 在启动命令或子代理前核对相关输入,避免执行后才发现其它验证组损坏。
+- 指纹不跳过目录或链接;只读测试配置的变化继续影响证据有效性。工作区授权边界保持独立。
+
+本版继续支持 DSH 0.2.1-rc.3、rc.4、rc.5。本地构建、类型检查与完整回归通过:446 项中 439 通过、7 跳过、0 失败;新增 14 项文件范围回归。跳过项不计入已验证能力。
+
+遇到文件范围错误时,先查看 status.file_scope_problems,使用 scope、plan_execution 或 plan_verification 将对应项修正为具体文件,再核对并重跑缺失、失败或过期的验证。无需手动修改任务 JSON。
diff --git a/package-lock.json b/package-lock.json
index 07a4afd..4227ceb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@godv61/dsh-task-engine",
- "version": "0.40.0",
+ "version": "0.40.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@godv61/dsh-task-engine",
- "version": "0.40.0",
+ "version": "0.40.1",
"license": "MIT",
"bin": {
"dsh-task-engine-enable": "preset/enable.mjs"
diff --git a/package.json b/package.json
index a1824e4..2daa7a2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@godv61/dsh-task-engine",
- "version": "0.40.0",
+ "version": "0.40.1",
"description": "Task-scoped engineering workflows for DeepSeek Harness: four complexity grades, optional project Skill initialization, verified tests, and review-time SonarQube checks.",
"type": "module",
"main": "lib/index.js",
@@ -75,7 +75,8 @@
".evidence-hardening-test.mjs",
".skill-loading-test.mjs",
".requirements-test.mjs",
- ".task-workspace-test.mjs"
+ ".task-workspace-test.mjs",
+ ".file-scope-test.mjs"
],
"license": "MIT",
"publishConfig": {
@@ -96,7 +97,7 @@
"scripts": {
"build": "tsc -p tsconfig.json && node build-client.mjs && node build-hook.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
- "test": "node .p0-test.mjs && node .acceptance.mjs && node .hook-consistency.mjs && node --test .resource-test.mjs .workflow-test.mjs .hook-test.mjs .hook-install-test.mjs .verification-test.mjs .gradle-report-test.mjs .preset-test.mjs .assessment-batch1.mjs .e2e-presets.mjs .revision-test.mjs .freeze-test.mjs .enforce-test.mjs .evidence-test.mjs .roundtrip-test.mjs .filter-test.mjs .codex-project-test.mjs .adaptive-test.mjs .sonar-credential-test.mjs .sonarlint-local-test.mjs .sonar-rule-catalog-test.mjs .codegraph-init-test.mjs .repair-test.mjs .init-progress-test.mjs .init-repository-test.mjs .execution-policy-test.mjs .verification-groups-test.mjs .boundary-fixes-test.mjs .rc3-runtime-test.mjs .delivery-kernel-test.mjs .evidence-hardening-test.mjs .skill-loading-test.mjs .requirements-test.mjs .task-workspace-test.mjs",
+ "test": "node .p0-test.mjs && node .acceptance.mjs && node .hook-consistency.mjs && node --test .resource-test.mjs .workflow-test.mjs .hook-test.mjs .hook-install-test.mjs .verification-test.mjs .gradle-report-test.mjs .preset-test.mjs .assessment-batch1.mjs .e2e-presets.mjs .revision-test.mjs .freeze-test.mjs .enforce-test.mjs .evidence-test.mjs .roundtrip-test.mjs .filter-test.mjs .codex-project-test.mjs .adaptive-test.mjs .sonar-credential-test.mjs .sonarlint-local-test.mjs .sonar-rule-catalog-test.mjs .codegraph-init-test.mjs .repair-test.mjs .init-progress-test.mjs .init-repository-test.mjs .execution-policy-test.mjs .verification-groups-test.mjs .boundary-fixes-test.mjs .rc3-runtime-test.mjs .delivery-kernel-test.mjs .evidence-hardening-test.mjs .skill-loading-test.mjs .requirements-test.mjs .task-workspace-test.mjs .file-scope-test.mjs",
"verify:package": "node scripts/verify-package.mjs",
"verify:dsh": "node scripts/verify-dsh-compat.mjs",
"prepare": "npm run build",
diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs
index 8e34fe6..f4996ea 100644
--- a/scripts/verify-package.mjs
+++ b/scripts/verify-package.mjs
@@ -125,7 +125,7 @@ try {
// Resource package behavior from the installed artifact, including the commit
// hook running for real inside throwaway repositories.
try {
- const out = execSync('node --test --test-reporter=tap .resource-test.mjs .workflow-test.mjs .hook-test.mjs .preset-test.mjs .adaptive-test.mjs .gradle-report-test.mjs .execution-policy-test.mjs .boundary-fixes-test.mjs .repair-test.mjs .init-repository-test.mjs .init-progress-test.mjs .rc3-runtime-test.mjs .delivery-kernel-test.mjs .evidence-hardening-test.mjs .skill-loading-test.mjs .requirements-test.mjs .task-workspace-test.mjs', { cwd: installedDir, encoding: 'utf8', stdio: 'pipe' })
+ const out = execSync('node --test --test-reporter=tap .resource-test.mjs .workflow-test.mjs .hook-test.mjs .preset-test.mjs .adaptive-test.mjs .gradle-report-test.mjs .execution-policy-test.mjs .boundary-fixes-test.mjs .repair-test.mjs .init-repository-test.mjs .init-progress-test.mjs .rc3-runtime-test.mjs .delivery-kernel-test.mjs .evidence-hardening-test.mjs .skill-loading-test.mjs .requirements-test.mjs .task-workspace-test.mjs .file-scope-test.mjs', { cwd: installedDir, encoding: 'utf8', stdio: 'pipe' })
check('in-package resource, workflow and hook behavior', /# fail 0/u.test(out))
} catch (error) {
const lines = String(error.stdout || error.stderr || error).split('\n')
diff --git a/skills/code-development/SKILL.md b/skills/code-development/SKILL.md
index 3759e63..c64e9c7 100644
--- a/skills/code-development/SKILL.md
+++ b/skills/code-development/SKILL.md
@@ -18,6 +18,8 @@ whenToUse: 自适应任务进入代码开发元技能时。
## 验证与交付范围
+所有 files 逐个登记具体普通文件,不能填写目录或通配符;尚未创建的文件可以提前登记。路径后来变成目录、链接或无法读取时,查看 status.file_scope_problems,使用 scope、plan_execution 或 plan_verification 修正对应清单;保持原验证与审核记录,不跳过错误路径或手改台账放行。
+
实施项应包含实现及其必要测试,不把本项必须的单测拆成依赖“本项 done”的下游项。用 `plan_verification` 登记测试组的稳定 `verification_group`、真实测试 `command`、全部输入 `files` 和 `item_ids`。只读构建配置放在测试输入,只有实际修改的文件才登记为交付;任务级集成检查可使用空 `item_ids`。
`verify verification_group=...` 返回 `group_result` 与任务整体 `task_verified`。本组 passed、其它组待运行时可先验收本项,不重跑已通过且输入不变的组。编译成功不能冒充测试通过;测试失败、全跳过或输入改变必须重新验证。默认回执只有摘要,完整日志使用 `status detail=true`,不要为查看日志重复执行测试。
diff --git a/skills/test-validation/SKILL.md b/skills/test-validation/SKILL.md
index cb8096b..4306bac 100644
--- a/skills/test-validation/SKILL.md
+++ b/skills/test-validation/SKILL.md
@@ -6,6 +6,8 @@ whenToUse: 自适应任务进入测试元技能时。
# 测试元技能
+验证计划的 files 应列出具体生产代码、测试和相关配置文件,不能填包目录或通配符;尚未创建的文件允许预先登记。遇到 INVALID_FILE_SCOPE 时先修正所指的任务或验证组路径,多个坏组可逐个重新登记;目录内代码不能靠“忽略目录”取得有效测试回执。
+
根据验收条件选择场景,运行真实测试命令,并通过 `dev_task verify` 保存回执。构建可作为辅助检查,但仅编译或跳过测试的命令不能作为测试组计划。失败时先修复再重新运行;测试范围应覆盖本次改动及其主要回归风险。
先查看 status.verification_planning 的各组状态,只对缺失、失败或输入已变化的组执行 `verify verification_group=...`。开发阶段已通过且输入未变的回执可直接用于本阶段;不因阶段切换重跑。每组独立保存真实回执,全部必要组通过且输入指纹有效才可验收。声明 files 时包含生产代码、测试及相关构建/配置;跨项依赖输入由引擎保守纳入。哈希只判断证据是否过期,测试覆盖仍需按验收条件核对。不把已有全局回执追认成多组通过。
diff --git a/src/client/TaskLedger.tsx b/src/client/TaskLedger.tsx
index 9b1896b..8a4783d 100644
--- a/src/client/TaskLedger.tsx
+++ b/src/client/TaskLedger.tsx
@@ -372,6 +372,13 @@ export function TaskLedger({
+ {!!selected.evidence_problems?.length && (
+
+
文件范围或证据无法核验
+
{selected.evidence_problems.map((problem, i) => - {problem}
)}
+
{selected.next_action}
+
+ )}
{!!selected.content?.stages.length && (
{selected.content.stages.map((v, i) => (
diff --git a/src/client/remote.ts b/src/client/remote.ts
index af9ec18..5733821 100644
--- a/src/client/remote.ts
+++ b/src/client/remote.ts
@@ -283,6 +283,7 @@ const taskLedgerViewSchema = z.object({
hash: z.string(), bytes: z.number(), updated_at: z.string(), retired_at: z.string().optional(), template: z.object({ skill_name: z.string(), path: z.string(), hash: z.string(), data_id: z.string() }).optional() })).optional() }).optional(),
delivery: deliveryViewSchema.optional(),
evidence_status: z.enum(['valid', 'stale', 'failed', 'blocked', 'missing', 'historical', 'unknown']).optional(),
+ evidence_problems: z.array(z.string()).optional(),
verification_groups: z.array(z.object({ id: z.string(), command: z.string(),
status: z.enum(['passed', 'failed', 'blocked', 'stale', 'missing']), test_count: z.number().nullable(),
checked_at: z.string().optional(), problems: z.array(z.string()) })).optional(),
diff --git a/src/controller.ts b/src/controller.ts
index 6668e3c..1439871 100644
--- a/src/controller.ts
+++ b/src/controller.ts
@@ -38,7 +38,7 @@ import { lastLocalRuleUpdate, prepareLocalAnalyzer } from './sonarlint-local.ts'
import { listSonarProjectRules, projectAnalyzedLanguages, type SonarProjectRuleView } from './sonar-rule-catalog.ts'
import { reviewedProfileCoverage } from './sonar-report.ts'
import { codeGraphEvidence, codeGraphInitHint, codeGraphStatus, prepareCodeGraph, type CodeGraphState } from './codegraph-init.ts'
-import { scopeFingerprint, verificationInputHashes } from './dev-task.ts'
+import { inspectTaskFiles } from './dev-task.ts'
import { groupEvidenceBlockers, verificationGroupViews } from './verification-state.ts'
import { initJob, type InitJobView } from './init-jobs.ts'
import { INIT_ATTEMPT_TIMEOUT_MS, initTimeBudget, withInitTimeout } from './init-timeout.ts'
@@ -1014,18 +1014,28 @@ export default class TaskEngineController extends TypertRemoteService {
if (!state || state.id !== id || ['id', 'title', 'stage', 'branch'].some(key => typeof (state as unknown as Record)[key] !== 'string') || (state.items !== undefined && !Array.isArray(state.items))) throw new Error('任务记录格式无效或编号与目录不一致:' + taskPath(id))
let evidenceStatus: TaskLedgerEntry['evidence_status'] = state.completed ? 'historical' : !state.verification?.receipt ? 'missing' : !state.verification.passed ? 'failed' : 'unknown'
let currentScope: string | undefined
- let groupHashes: Record | undefined
- if (!state.completed && (state.verification_version || state.verification?.passed || state.review?.outcome === 'pass')) {
+ let groupHashes: Record | undefined
+ const evidenceProblems: string[] = []
+ let groupProblems: Record = {}
+ if (!state.completed && state.delivery?.intent !== 'analysis') {
+ // Workspace authorization remains outside the recoverable evidence
+ // boundary: an unregistered task root must not be inspected.
const evidenceRoot = await this.authorizedPath(state.root ?? path)
- currentScope = await scopeFingerprint(fs, { ...state, root: evidenceRoot }, path)
- if (state.verification_version) groupHashes = await verificationInputHashes(fs, { ...state, root: evidenceRoot }, path)
+ try {
+ const inspected = await inspectTaskFiles(fs, { ...state, root: evidenceRoot }, path)
+ currentScope = inspected.scope
+ groupHashes = inspected.hashes
+ groupProblems = inspected.groupProblems
+ evidenceProblems.push(...inspected.problems)
+ } catch (error) { evidenceProblems.push(error instanceof Error ? error.message : String(error)) }
}
if (!state.completed && state.verification?.passed && state.verification.receipt) {
evidenceStatus = state.verification_version
? groupEvidenceBlockers(state, groupHashes).length ? 'stale' : 'valid'
: state.verification.receipt.scope_hash === currentScope ? 'valid' : 'stale'
}
- const groupViews = verificationGroupViews(state, groupHashes)
+ const groupViews = verificationGroupViews(state, groupHashes).map(group => Object.hasOwn(groupProblems, group.id)
+ ? { ...group, status: 'blocked' as const, problems: [...group.problems, ...groupProblems[group.id]!] } : group)
if (!state.completed && state.verification_version) {
evidenceStatus = groupViews.some(group => group.status === 'blocked') ? 'blocked'
: groupViews.some(group => group.status === 'failed') ? 'failed'
@@ -1033,7 +1043,8 @@ export default class TaskEngineController extends TypertRemoteService {
: groupViews.some(group => group.status === 'missing') || !groupViews.length ? 'missing'
: groupEvidenceBlockers(state).length ? 'stale' : 'valid'
}
- const reviewStatus: TaskLedgerEntry['review_status'] = state.completed ? 'historical' : state.review?.outcome === 'pass'
+ if (evidenceProblems.length) evidenceStatus = 'blocked'
+ const reviewStatus: TaskLedgerEntry['review_status'] = state.completed ? 'historical' : evidenceProblems.length ? 'blocked' : state.review?.outcome === 'pass'
? evidenceStatus !== 'valid' || reviewEvidenceBlockers(state, currentScope ?? '').length ? 'stale' : 'valid'
: state.review?.outcome === 'blocked' ? 'blocked' : 'missing'
const awaitingCommit = !state.completed && evidenceStatus === 'valid' && reviewStatus === 'valid'
@@ -1045,6 +1056,7 @@ export default class TaskEngineController extends TypertRemoteService {
...(requirements ? { requirements } : {}),
...(state.delivery ? { delivery: deliveryView(state)! } : {}),
evidence_status: evidenceStatus,
+ ...(evidenceProblems.length ? { evidence_problems: evidenceProblems } : {}),
...(groupViews.length ? { verification_groups: groupViews } : {}),
review_status: reviewStatus,
...(state.complexity ? { complexity: state.complexity } : {}),
@@ -1054,7 +1066,7 @@ export default class TaskEngineController extends TypertRemoteService {
...(state.completed ? { completed_at: state.completed.at } : {}),
...(state.related_task ? { related_task: state.related_task } : {}),
items_done: (state.items ?? []).filter(item => item.status === "done").length,
- next_action: state.completed ? "已归档;后续改动请新建关联任务" : taskExecutionProblem(state) ?? (requirements && requirements.status !== 'confirmed' ? '澄清需求并生成总结;等待用户确认当前版本后再实施' : state.delivery?.intent === 'analysis' ? '主会话查证并及时交付结论;submit_analysis 接受证据与方案' : awaitingCommit ? "等待 Git 提交登记(尚未归档);请保留当前任务继续推进" : state.stage === (state.flow?.config.stages.at(-1) ?? "完成") ? "调用 complete 归档" : state.stage === "代码开发" ? (state.items.length > 0 && state.items.every(item => item.status === 'done') ? "实施项已完成;核对逐项审查和阶段门禁后继续推进" : "继续实施并完成任务项") : state.stage === "测试" ? "运行并记录测试结果" : state.stage === "代码审核" ? "完成代码审核" : "完成当前阶段产物与交接"),
+ next_action: evidenceProblems.length ? '文件范围无法核验;使用 scope 或 plan_verification 将错误路径改为具体文件,再核对并补验。' : state.completed ? "已归档;后续改动请新建关联任务" : taskExecutionProblem(state) ?? (requirements && requirements.status !== 'confirmed' ? '澄清需求并生成总结;等待用户确认当前版本后再实施' : state.delivery?.intent === 'analysis' ? '主会话查证并及时交付结论;submit_analysis 接受证据与方案' : awaitingCommit ? "等待 Git 提交登记(尚未归档);请保留当前任务继续推进" : state.stage === (state.flow?.config.stages.at(-1) ?? "完成") ? "调用 complete 归档" : state.stage === "代码开发" ? (state.items.length > 0 && state.items.every(item => item.status === 'done') ? "实施项已完成;核对逐项审查和阶段门禁后继续推进" : "继续实施并完成任务项") : state.stage === "测试" ? "运行并记录测试结果" : state.stage === "代码审核" ? "完成代码审核" : "完成当前阶段产物与交接"),
verification_passed: state.completed ? state.verification?.passed ?? false : evidenceStatus === 'valid',
review_outcome: state.review?.outcome ?? 'pending',
...(state.sonar_policy ? { sonar: {
diff --git a/src/dev-task.ts b/src/dev-task.ts
index 9909585..9faf0d3 100644
--- a/src/dev-task.ts
+++ b/src/dev-task.ts
@@ -1,5 +1,6 @@
import { readText, writeText, loadTask, writeTask, taskPath, listTaskIds, taskNamespace, type WriteFs } from './task-store.ts'
-import { isEngineMeta, isTaskLedgerPath, isTaskReviewPath } from './task-paths.ts'
+import { isEngineMeta, isTaskReviewPath } from './task-paths.ts'
+import { TaskFileError, validateTaskFiles } from './task-files.ts'
/**
* The model-facing `dev_task` tool: the hard engineering-delivery gate.
*
@@ -1296,7 +1297,9 @@ function normalizeItems(items: { id?: string; title?: string; status?: 'todo' |
/** Hash declared deliverables through host FS; task bookkeeping is excluded to avoid self-invalidation. */
export async function scopeFingerprint(fs: Fs, state: TaskState, cwd?: string, includeGovernance = false): Promise {
const entries = []
- for (const path of [...new Set(state.files)].filter(path => includeGovernance || !isEngineMeta(path)).sort()) {
+ const paths = [...new Set(state.files)].filter(path => includeGovernance || !isEngineMeta(path)).sort()
+ await validateTaskFiles(fs, paths, state.root ?? cwd ?? '', 'fingerprint', 'fingerprint')
+ for (const path of paths) {
assertInsideRoot(state.root ?? cwd ?? '', state.root ?? cwd ?? '', path)
const root = state.root ?? cwd
const target = await fs.resolve(path, root === undefined ? undefined : { cwd: root })
@@ -1305,7 +1308,7 @@ export async function scopeFingerprint(fs: Fs, state: TaskState, cwd?: string, i
try {
const info = await fs.lstat(path, root === undefined ? undefined : { cwd: root })
if (info !== undefined) {
- if (info.type !== undefined && info.type !== 'file') throw new Error(`task scope contains a non-regular file: ${path}`)
+ if (info.type !== undefined && info.type !== 'file') throw new TaskFileError(path, 'fingerprint', 'non-regular file (' + info.type + ')')
executable = false
if (process.platform !== 'win32') {
try { executable = (lstatSync(resolve(root ?? '', path)).mode & 0o111) !== 0 }
@@ -1317,7 +1320,8 @@ export async function scopeFingerprint(fs: Fs, state: TaskState, cwd?: string, i
: Buffer.from(await reader.readText(target), 'utf8')
}
} catch (error) {
- if (!['ENOENT', 'FS_NOT_FOUND'].includes((error as { code?: string }).code ?? '')) throw error
+ if (!['ENOENT', 'FS_NOT_FOUND'].includes((error as { code?: string }).code ?? ''))
+ throw error instanceof TaskFileError ? error : new TaskFileError(path, 'fingerprint', error instanceof Error ? error.message : String(error))
}
entries.push([path, content === undefined ? null : hashBytes(content), executable])
}
@@ -1330,6 +1334,45 @@ export async function verificationInputHashes(fs: Fs, state: TaskState, cwd?: st
[group.id, await scopeFingerprint(fs, { ...state, files: groupInputs(state, group) }, cwd, true)])))
}
+/** A read-only diagnostic projection. Missing hashes never constitute valid evidence. */
+export async function inspectTaskFiles(fs: Fs, state: TaskState, cwd?: string) {
+ const problems: string[] = [], hashes: Record = Object.create(null),
+ groupProblems: Record = Object.create(null)
+ let scope: string | undefined
+ const root = state.root ?? cwd ?? ''
+ const message = (owner: string, error: unknown) => owner + ': ' + (error instanceof Error ? error.message : String(error))
+ try {
+ await validateTaskFiles(fs, state.files, root, 'scope', 'delivery')
+ scope = await scopeFingerprint(fs, state, cwd)
+ } catch (error) { problems.push(message('scope', error)) }
+ for (const item of state.items) {
+ try { await validateTaskFiles(fs, item.execution_plan?.files ?? [], root, 'item ' + item.id, 'delivery') }
+ catch (error) { problems.push(message('item ' + item.id, error)) }
+ }
+ for (const group of Object.values(state.verification_groups ?? {})) {
+ try {
+ const files = groupInputs(state, group)
+ await validateTaskFiles(fs, files, root, 'verification group ' + group.id, 'verification')
+ hashes[group.id] = await scopeFingerprint(fs, { ...state, files }, cwd, true)
+ } catch (error) {
+ const problem = message('verification group ' + group.id, error)
+ groupProblems[group.id] = [problem]
+ problems.push(problem)
+ }
+ }
+ return { scope, hashes, problems, groupProblems }
+}
+
+/** Fail before launching any command or child, including for another invalid group. */
+async function assertTaskFileInputs(fs: Fs, state: TaskState, cwd?: string): Promise {
+ const root = state.root ?? cwd ?? ''
+ await validateTaskFiles(fs, state.files, root, 'scope', 'delivery')
+ for (const item of state.items)
+ await validateTaskFiles(fs, item.execution_plan?.files ?? [], root, 'item ' + item.id, 'delivery')
+ for (const group of Object.values(state.verification_groups ?? {}))
+ await validateTaskFiles(fs, groupInputs(state, group), root, 'verification group ' + group.id, 'verification')
+}
+
async function itemVerificationBlockers(fs: Fs, state: TaskState, id: string, cwd?: string): Promise {
if (state.verification_version) return groupEvidenceBlockers(state, await verificationInputHashes(fs, state, cwd), id)
return !state.verification.passed || !state.verification.receipt?.test_summary?.count
@@ -1693,7 +1736,7 @@ export function registerDevTask(ctx: Context): void {
depends_on: { type: 'array', items: { type: 'string' }, description: 'plan_execution: earlier registered item IDs that must finish first.' },
changes: { type: 'array', description: 'plan_execution: explicitly planned delete/rename operations. Keep old paths and rename destinations in files and task scope. The engine captures tracked Git and pre-change worktree text for readonly review.',
items: { type: 'object', properties: { path: { type: 'string' }, kind: { type: 'string', enum: ['delete', 'rename'] }, to: { type: 'string' } }, additionalProperties: false } },
- verification_group: { type: 'string', description: 'plan_verification/verify: stable test-group ID, for example backend or frontend. Plan each group with command, files including tests/configuration, and item_ids. Verify executes its declared command; no group is inferred from printed output.' },
+ verification_group: { type: 'string', description: 'plan_verification/verify: stable test-group ID, for example backend or frontend. Plan each group with command, item_ids, and concrete regular files (production, tests, relevant configuration), not directories or wildcards. Planned files may not exist yet. Verify executes its declared command; no group is inferred from printed output.' },
item_ids: { type: 'array', items: { type: 'string' }, description: 'plan_verification: implementation items validated by this group. Each item includes its own implementation and tests, never a later test item depending on its completion.' },
affected_items: { type: 'array', items: { type: 'string' }, description: 'revise(defect): explicit affected item IDs. Shared files and downstream consumers also reopen. Omit for unknown or task-wide impact. Requirements/solution revisions remain task-wide.' },
dispatch_role: { type: 'string', enum: ['implementation', 'review'], description: 'recover_dispatch: which interrupted child to recover; default implementation.' },
@@ -1714,7 +1757,7 @@ export function registerDevTask(ctx: Context): void {
files: {
type: 'array',
items: { type: 'string' },
- description: 'Repository-relative delivery paths to change (create/scope), or exact paths being committed (commit, required when file_scope is enabled). Read-only build/config inputs belong in plan_verification files. For commit hash recording, repeat the same files and message used for the actual Git commit.',
+ description: 'Concrete repository-relative regular file paths, not directories or wildcards. Planned new files may not exist yet. create/scope declare delivery paths; plan_verification declares production, tests and read-only build/config inputs. commit lists the exact changed files; for hash recording repeat the same files and message used for the actual Git commit.',
},
message: { type: 'string', description: 'Exact Git commit subject (commit). Use the same text when preparing the commit, executing Git and recording its hash; do not paraphrase it at the hash-recording step.' },
revision_kind: { type: 'string', enum: ['requirement', 'solution', 'defect'], description: 'Why the task is going back (revise). Decides which conclusions are invalidated: requirement clears its confirmation and everything downstream; solution keeps the requirement agreed; defect clears only the evidence about the old implementation.' },
@@ -2072,7 +2115,7 @@ export function registerDevTask(ctx: Context): void {
if (a.files?.length) state.delivery!.references = a.files
state.execution_policy = a.execution_policy ?? 'auto'
}
- state.files = isAnalysis(state) ? [] : a.files ?? []
+ state.files = isAnalysis(state) ? [] : await validateTaskFiles(fs, a.files ?? [], root, 'create', 'delivery')
let baselineNotice = ''
if (flow.config.requirement_review) baselineNotice += '\nBusiness writes are blocked until the human confirms this requirement version. Read related source, clarify important gaps, record requirement fields, prepare_requirement, then confirm_requirement expected_hash. Low complexity uses a short summary in its initial stage.'
if (sonarPolicy?.source === 'ide-local') {
@@ -2180,7 +2223,21 @@ export function registerDevTask(ctx: Context): void {
const missingSkillFiles = await missingSkillRefs(binding, fs, cwd)
const changedSkillFiles = await changedSkillRefs(binding, fs, cwd, state.flow?.resources)
const missingSkills = skillBlockers(state, workflow, session)
- const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd)
+ const inspected = await inspectTaskFiles(fs, state, cwd)
+ const fileProblems = inspected.problems
+ let staleEvidence: string[]
+ try { staleEvidence = fileProblems.length ? [...fileProblems] : await evidenceBlockers(fs, state, workflow, cwd) }
+ catch (error) {
+ fileProblems.push('Evidence could not be checked: ' + (error instanceof Error ? error.message : String(error)))
+ staleEvidence = [...fileProblems]
+ }
+ let currentSonarBlockers: string[]
+ try { currentSonarBlockers = fileProblems.length ? [...fileProblems] : await sonarBlockers(fs, state, cwd) }
+ catch (error) {
+ fileProblems.push('Sonar evidence could not be checked: ' + (error instanceof Error ? error.message : String(error)))
+ currentSonarBlockers = [...fileProblems]
+ staleEvidence = [...new Set([...staleEvidence, ...fileProblems])]
+ }
const checkpoint = commitCheckpoint(state, workflow)
// The flow's verification requirement is reported separately from staleness:
// one says the receipt no longer matches the tree, the other says the flow
@@ -2191,7 +2248,7 @@ export function registerDevTask(ctx: Context): void {
const completionIssues = state.completed === undefined
? [...completionBlockers(state, workflow), ...missingSkills,
...skillResourceBlockers(missingSkillFiles, state.stage),
- ...resourceBlockers(missingRules, state.stage), ...staleEvidence, ...await sonarBlockers(fs, state, cwd)]
+ ...resourceBlockers(missingRules, state.stage), ...staleEvidence, ...currentSonarBlockers]
: []
const planItemBlockers = taskPlanItemBlockers(state)
const commitState = checkpoint.allowed && commitBlockers.length
@@ -2243,6 +2300,7 @@ export function registerDevTask(ctx: Context): void {
}),
skill_blockers: missingSkills,
evidence_blockers: staleEvidence,
+ file_scope_problems: fileProblems,
verification_blockers: verification,
// Rules in force here, each named with the layer it came from, plus the
// ones that could not be found and the legacy stage-level names still
@@ -2266,7 +2324,7 @@ export function registerDevTask(ctx: Context): void {
sonar: state.sonar_policy === undefined ? { enabled: false } : {
enabled: true, mode: state.sonar_policy.mode, project_key: state.sonar_policy.project_key,
source: state.sonar_policy.source ?? 'ci', reference_branch: state.sonar_policy.reference_branch,
- audit: state.sonar_audit ?? null, blockers: await sonarBlockers(fs, state, cwd),
+ audit: state.sonar_audit ?? null, blockers: currentSonarBlockers,
prior_audits: state.sonar_history?.length ?? 0,
},
learned_rules: state.learned_rules ?? [],
@@ -2671,12 +2729,7 @@ export function registerDevTask(ctx: Context): void {
if (!state.delivery || !['任务编排', '代码开发', '开发'].includes(state.stage)) throw new Error('plan requires a new implementation contract in orchestration or development')
if (!a.plans?.length) throw new Error('plan requires complete plans')
state.items = normalizeItems(a.plans.map(plan => ({ id: plan.id, title: plan.title })), state.items)
- for (const plan of a.plans) for (const file of plan.files) {
- assertInsideRoot(state.root!, state.root!, file)
- if (isSensitivePath(file)) throw new Error(`sensitive delivery path: ${file}`)
- const info = await fs.lstat(file, { cwd: state.root! })
- if (info && info.type !== 'file') throw new Error(`plan contains a non-regular file: ${file}`)
- }
+ for (const plan of a.plans) await validateTaskFiles(fs, plan.files, state.root ?? cwd ?? '', 'plan', 'delivery')
state.files = [...new Set([...state.files, ...a.plans.flatMap(plan => plan.files)])]
for (const input of a.plans) {
const item = requireItem(state, input.id)
@@ -2711,18 +2764,15 @@ export function registerDevTask(ctx: Context): void {
break
}
case 'scope':
- state.files = a.files ?? []
+ state.files = await validateTaskFiles(fs, a.files ?? [], state.root ?? cwd ?? '', 'scope', 'delivery')
note = `file scope set to ${state.files.length} files${state.files.length > 0 ? ': ' + state.files.join(', ') : ' (empty — commits are blocked until files are declared)'}`
break
case 'plan_verification': {
if (!a.verification_group || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/u.test(a.verification_group)) throw new Error('plan_verification requires verification_group, e.g. backend')
- if (!a.command?.trim() || !a.files?.length || !a.item_ids) throw new Error('plan_verification requires command, files (implementation + tests + relevant configuration), and item_ids (empty for task-level integration checks)')
+ if (!a.command?.trim() || !a.files?.length || !a.item_ids) throw new Error('plan_verification requires command, concrete regular files (not directories; planned new files may be missing), and item_ids (empty for task-level integration checks)')
const problem = verificationPlanProblem(a.command)
if (problem) throw new Error(problem)
- for (const file of a.files) {
- assertInsideRoot(state.root ?? cwd ?? '', state.root ?? cwd ?? '', file)
- if (isTaskLedgerPath(file)) throw new Error('a test group cannot include its own changing task ledger')
- }
+ await validateTaskFiles(fs, a.files, state.root ?? cwd ?? '', 'plan_verification ' + a.verification_group, 'verification')
for (const id of a.item_ids) requireItem(state, id)
if (state.items.some(item => ['running', 'starting'].includes(item.dispatch?.status ?? ''))) throw new Error('wait for active children before changing verification plans')
const group = { id: a.verification_group, command: a.command.trim(), files: [...new Set(a.files)], item_ids: [...new Set(a.item_ids)], key: '', at: new Date().toISOString() }
@@ -2734,8 +2784,15 @@ export function registerDevTask(ctx: Context): void {
state.verification_version = 1
;(state.verification_groups ??= {})[group.id] = group
state.review = { outcome: 'pending' }
- await refreshVerification(state, await verificationInputHashes(fs, state, cwd), await scopeFingerprint(fs, state, cwd))
- note = JSON.stringify({ group, blockers: groupPlanningBlockers(state), next_action: `Implement this complete unit, then verify verification_group=${group.id}. verify itself executes the real command and captures its receipt; do not run the same registered suite in a native terminal first. Set timeout_ms to a realistic budget. A later test item must not depend on this unit being done.` })
+ const inspected = await inspectTaskFiles(fs, state, cwd)
+ if (inspected.problems.length) {
+ state.verification.passed = false
+ state.verification.problems = [...groupEvidenceBlockers(state, inspected.hashes), ...inspected.problems]
+ delete state.verification.scope_hash
+ } else refreshVerification(state, inspected.hashes, inspected.scope!)
+ note = JSON.stringify({ group, blockers: [...groupPlanningBlockers(state), ...inspected.problems], next_action: inspected.problems.length
+ ? 'Other file inputs are still invalid. Correct each named scope/item/group with scope, plan_execution or plan_verification; old evidence is retained but cannot authorize delivery.'
+ : `Implement this complete unit, then verify verification_group=${group.id}. verify itself executes the real command and captures its receipt; do not run the same registered suite in a native terminal first. Set timeout_ms to a realistic budget. A later test item must not depend on this unit being done.` })
break
}
case 'items':
@@ -2756,7 +2813,7 @@ export function registerDevTask(ctx: Context): void {
if (!['任务编排', '开发', '代码开发'].includes(state.stage)) throw new Error('plan_execution runs in orchestration or implementation stage')
const item = requireItem(state, a.item_id)
if (item.status !== 'todo') throw new Error('reopen the item as todo before replanning execution')
- for (const path of a.files ?? []) assertInsideRoot(state.root ?? cwd ?? '', state.root ?? cwd ?? '', path)
+ await validateTaskFiles(fs, a.files ?? [], state.root ?? cwd ?? '', 'plan_execution', 'delivery')
const plan = makeExecutionPlan(state, item, a, executionCapabilities(ctx, !!exec.agent))
validateDeliveryChanges(a.changes ?? [], plan.files)
if (a.changes?.length) plan.changes = await captureDeliveryChanges(ctx, fs, state, a.changes, exec, await resolveWriteMode(ctx, a, exec))
@@ -2770,6 +2827,7 @@ export function registerDevTask(ctx: Context): void {
break
}
case 'dispatch': {
+ await assertTaskFileInputs(fs, state, cwd)
const item = requireItem(state, a.item_id)
const plan = item.execution_plan
if (state.delegation_version && !plan) throw new Error('call plan_execution before dispatch; record boundary, files, acceptance and reason')
@@ -3090,6 +3148,7 @@ ${structuredReview ? 'Finish by calling the host structured_output tool with' :
break
}
case 'verify': {
+ await assertTaskFileInputs(fs, state, cwd)
if (a.timeout_ms !== undefined && (!Number.isSafeInteger(a.timeout_ms) || a.timeout_ms < 1000 || a.timeout_ms > VERIFY_TIMEOUT_MS))
throw new Error(`verify timeout_ms must be an integer from 1000 to ${VERIFY_TIMEOUT_MS}`)
const groups = Object.values(state.verification_groups ?? {})
diff --git a/src/task-files.ts b/src/task-files.ts
new file mode 100644
index 0000000..213bfe4
--- /dev/null
+++ b/src/task-files.ts
@@ -0,0 +1,51 @@
+/** Concrete file inputs shared by declaration, fingerprinting and recovery. */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-fs'
+import { relative, resolve, sep } from 'node:path'
+import { assertInsideRoot } from './path-guard.ts'
+import { isSensitivePath } from './project.ts'
+import { isTaskLedgerPath } from './task-paths.ts'
+
+type Fs = Context['fs']
+export type FileUse = 'delivery' | 'verification' | 'fingerprint'
+
+export class TaskFileError extends Error {
+ readonly code = 'INVALID_FILE_SCOPE'
+ constructor(readonly path: string, owner: string, reason: string) {
+ super('INVALID_FILE_SCOPE: ' + owner + ': ' + path + ': ' + reason
+ + '; declare concrete regular files, not directories; correct scope or plan_verification before retrying')
+ this.name = 'TaskFileError'
+ }
+}
+
+/** Missing files are legitimate planned additions; an existing link or directory is not. */
+export async function validateTaskFiles(fs: Fs, paths: readonly string[], root: string, owner: string, use: FileUse): Promise {
+ const checkedParents = new Set()
+ for (const path of new Set(paths)) {
+ try {
+ if (typeof path !== 'string' || !path.trim()) throw new Error('file path must be a non-empty string')
+ assertInsideRoot(root, root, path)
+ const local = relative(resolve(root), resolve(root, path)).replace(/\\/gu, '/')
+ const canonical = process.platform === 'win32' ? local.toLowerCase() : local
+ if (/[*?]/u.test(local)) throw new Error('wildcards are not concrete file paths')
+ if (use !== 'fingerprint' && isTaskLedgerPath(canonical)) throw new Error('a changing task ledger cannot be a delivery or test input')
+ if (use === 'delivery' && isSensitivePath(canonical)) throw new Error('sensitive delivery path: ' + path)
+ // Inspect ancestors through the same host FS: a lexical in-root path can
+ // still cross a linked parent, including when the final file is missing.
+ const parts = local.split('/').filter(Boolean)
+ for (let i = 1; i < parts.length; i++) {
+ const parent = parts.slice(0, i).join(sep)
+ if (checkedParents.has(parent)) continue
+ const info = await fs.lstat(parent, { cwd: root })
+ if (info?.type !== undefined && info.type !== 'directory') throw new Error('parent is not an ordinary directory: ' + parent)
+ checkedParents.add(parent)
+ }
+ const info = await fs.lstat(path, { cwd: root })
+ if (info?.type !== undefined && info.type !== 'file') throw new Error('non-regular file (' + info.type + ')')
+ } catch (error) {
+ if (['ENOENT', 'FS_NOT_FOUND'].includes((error as { code?: string }).code ?? '')) continue
+ throw new TaskFileError(String(path), owner, error instanceof Error ? error.message : String(error))
+ }
+ }
+ return [...new Set(paths)]
+}
diff --git a/src/task-ledger-document.ts b/src/task-ledger-document.ts
index d3e9ac3..65d0b05 100644
--- a/src/task-ledger-document.ts
+++ b/src/task-ledger-document.ts
@@ -57,6 +57,8 @@ export function taskDocument(t: TaskLedgerEntry): string {
`状态:${lifecycleLabel(t)} · 当前阶段:${t.stage} · ${intentLabel(t)}`,
`分支:${t.branch || '未记录'}\n\n更新:${t.updated_at ? formatEastEightTime(t.updated_at) : '未记录'}\n\n下一步:${t.next_action}`,
]
+ if (t.evidence_problems?.length)
+ sections.push('## 文件范围或证据无法核验\n\n' + t.evidence_problems.map(problem => '- ' + problem).join('\n'))
if (r) {
sections.push(
`## 需求确认\n\n${requirementLabels[r.status]}${r.version ? ` · v${r.version}` : ''}\n\n${r.path ?? '尚未生成需求总结'}`,
diff --git a/src/task-ledger-types.ts b/src/task-ledger-types.ts
index 25b2866..18c7df5 100644
--- a/src/task-ledger-types.ts
+++ b/src/task-ledger-types.ts
@@ -39,6 +39,7 @@ export interface TaskLedgerEntry {
complexity?: string
delivery?: DeliveryView
evidence_status?: 'valid' | 'stale' | 'failed' | 'blocked' | 'missing' | 'historical' | 'unknown'
+ evidence_problems?: string[]
verification_groups?: VerificationGroupView[]
review_status?: 'valid' | 'stale' | 'missing' | 'blocked' | 'historical'
risk_level?: string
diff --git a/src/task-output.ts b/src/task-output.ts
index 2c13ef4..a132a9a 100644
--- a/src/task-output.ts
+++ b/src/task-output.ts
@@ -45,7 +45,8 @@ export function statusView(value: Record, state: TaskState, detail
selection?: { section?: 'items' | 'verification' | 'artifacts' | 'bindings' | 'sonar' | undefined; itemId?: string | undefined; groupId?: string | undefined }): Record {
if (detail && selection?.section) {
const section = selection.section
- const identity = { id: state.id, stage: state.stage, section }
+ const identity = { id: state.id, stage: state.stage, section,
+ ...(value.file_scope_problems?.length ? { file_scope_problems: value.file_scope_problems } : {}) }
if (section === 'items') return { ...identity, items: selection.itemId ? state.items.filter(item => item.id === selection.itemId) : state.items }
if (section === 'verification') return selection.groupId
? { ...identity, verification_group: selection.groupId, group: state.verification_groups?.[selection.groupId], latest_run: latestGroupRun(state, selection.groupId) }
From db420a4ae9c668546c945b85978633edacd15168 Mon Sep 17 00:00:00 2001
From: gongw <294669482@qq.com>
Date: Thu, 5 Nov 2026 12:34:58 +0800
Subject: [PATCH 2/2] test: isolate client codec probe from host React
---
.file-scope-test.mjs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.file-scope-test.mjs b/.file-scope-test.mjs
index 117bd93..0959b19 100644
--- a/.file-scope-test.mjs
+++ b/.file-scope-test.mjs
@@ -6,7 +6,6 @@ import { join, dirname, resolve, sep } from 'node:path'
import { tmpdir } from 'node:os'
import { spawnSync } from 'node:child_process'
import { runInNewContext } from 'node:vm'
-import { createRequire } from 'node:module'
import { registerDevTask, scopeFingerprint, verificationInputHashes } from './lib/dev-task.js'
import { validateTaskFiles } from './lib/task-files.js'
import Controller from './lib/controller.js'
@@ -137,8 +136,9 @@ test('future directory stays visible in status, ledger, transport and Markdown;
let registration, contribution
runInNewContext(readFileSync('lib/client.js', 'utf8'), { window: { __ModuleLoader__: { load(value) { registration = value } } } })
- const require = createRequire(import.meta.url)
- const client = registration.factory(name => name === 'react' || name === 'react/jsx-runtime' ? require(name) : {})
+ // React is provided by the DSH browser host. Codec registration does not
+ // render components and must also run in a clean npm consumer without React.
+ const client = registration.factory(() => ({}))
const stop = new Error('descriptors captured')
await assert.rejects(client.apply({ remote: { $mount: async value => { contribution = value; throw stop } } }), error => error === stop)
const method = contribution.descriptors.find(d => d.method === 'readTasks')