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..0959b19 --- /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 { 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 } } } }) + // 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') + 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.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) }