sillyspec 3.20.7 → 3.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/change-list.js +51 -16
- package/src/contract-matrix.js +67 -0
- package/src/stages/execute.js +37 -18
- package/src/stages/plan-postcheck.js +234 -0
- package/src/stages/plan.js +13 -0
- package/docs/brainstorm-plan-contract.md +0 -64
- package/docs/plan-execute-contract.md +0 -123
- package/docs/platform-scan-protocol.md +0 -298
- package/docs/revision-mode.md +0 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +0 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +0 -223
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +0 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +0 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +0 -211
- package/docs/sillyspec/file-lifecycle.md +0 -143
- package/docs/workflow-contract-regression.md +0 -106
- package/docs/worktree-isolation.md +0 -252
- package/test/brainstorm-plan-contract.test.mjs +0 -273
- package/test/check-syntax.mjs +0 -26
- package/test/cli-top-level-aliases.test.mjs +0 -174
- package/test/contract-artifacts.test.mjs +0 -323
- package/test/decision-ref-version.mjs +0 -85
- package/test/decision-supersede.test.mjs +0 -277
- package/test/knowledge-match.test.mjs +0 -231
- package/test/plan-execute-contract.test.mjs +0 -357
- package/test/plan-optimization.test.mjs +0 -572
- package/test/platform-artifacts.test.mjs +0 -190
- package/test/platform-failure-samples.test.mjs +0 -199
- package/test/platform-recovery-chain.test.mjs +0 -179
- package/test/platform-recovery.test.mjs +0 -167
- package/test/platform-scan-p0.test.mjs +0 -186
- package/test/quick-recommend.test.mjs +0 -146
- package/test/revision-v1.test.mjs +0 -1145
- package/test/run-sanitize-project-name.test.mjs +0 -51
- package/test/run-scan-postcheck-fail.test.mjs +0 -64
- package/test/run-scan-project-parse.test.mjs +0 -200
- package/test/run-tests.mjs +0 -48
- package/test/runtime-cleanup-keeps-worktree.test.mjs +0 -107
- package/test/scan-docs-yaml-placeholders.test.mjs +0 -84
- package/test/scan-knowledge.test.mjs +0 -175
- package/test/scan-paths.test.mjs +0 -68
- package/test/scan-postcheck-project-priority.test.mjs +0 -85
- package/test/scan-postcheck.test.mjs +0 -197
- package/test/scan-workflow-anyfailed-block.test.mjs +0 -52
- package/test/spec-dir.test.mjs +0 -206
- package/test/stage-contract-failed-post-check.test.mjs +0 -102
- package/test/stage-contract.test.mjs +0 -301
- package/test/stage-definitions.test.mjs +0 -39
- package/test/wait-gates.test.mjs +0 -501
- package/test/workflow-spec-base.test.mjs +0 -142
- package/test/worktree-deps-provision.test.mjs +0 -148
- package/test/worktree-guard.test.mjs +0 -136
- package/test/worktree-native-overlay.test.mjs +0 -188
package/package.json
CHANGED
package/src/change-list.js
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 归一化清单/allowed_paths 中的路径项:
|
|
5
|
+
* - 去反引号、去行内括号注释(`src/foo.js (新增)` / `src/foo.js(说明)`)
|
|
6
|
+
* - 统一为正斜杠、去首尾空白与尾部斜杠
|
|
7
|
+
* @param {string} raw
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
function normalizeEntry(raw) {
|
|
11
|
+
if (!raw) return ''
|
|
12
|
+
return raw
|
|
13
|
+
.replace(/`/g, '')
|
|
14
|
+
.replace(/\s*([^)]*)\s*$/, '')
|
|
15
|
+
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
16
|
+
.replace(/\\/g, '/')
|
|
17
|
+
.replace(/\/+$/, '')
|
|
18
|
+
.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 从 design.md 解析文件变更清单。兼容两种真实写法:
|
|
23
|
+
* ① 表格:`| 操作 | 文件路径 | 说明 |`(brainstorm 模板默认)
|
|
24
|
+
* ② 分类列表:`### 新增文件` / `### 修改文件` / `### 不修改文件` 下的 `- path`
|
|
25
|
+
* 始终忽略 `.sillyspec/` 内的路径与占位符;「不修改/保留」子段下的路径会被排除。
|
|
5
26
|
* @param {string} designMdPath - design.md 文件路径
|
|
6
27
|
* @returns {Set<string>} 文件路径集合(相对路径,如 "src/worktree.js")
|
|
7
28
|
*/
|
|
@@ -10,7 +31,7 @@ export function parseFileChangeList(designMdPath) {
|
|
|
10
31
|
|
|
11
32
|
if (!designMdPath || !existsSync(designMdPath)) return result
|
|
12
33
|
|
|
13
|
-
const content = readFileSync(designMdPath, 'utf8')
|
|
34
|
+
const content = readFileSync(designMdPath, 'utf8').replace(/\r\n/g, '\n')
|
|
14
35
|
|
|
15
36
|
// 定位"文件变更清单"标题
|
|
16
37
|
const sectionRegex = /^#{2,3}\s*文件变更清单/m
|
|
@@ -24,28 +45,42 @@ export function parseFileChangeList(designMdPath) {
|
|
|
24
45
|
? afterSection.slice(0, nextSectionMatch.index)
|
|
25
46
|
: afterSection
|
|
26
47
|
|
|
27
|
-
|
|
48
|
+
const isExcluded = (p) => !p || p === '—' || p === '-' || p.startsWith('.sillyspec/')
|
|
49
|
+
|
|
28
50
|
const lines = relevantContent.split('\n')
|
|
29
51
|
let headerSkipped = false
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!line.startsWith('|') || /^\|[-:\s|]+\|$/.test(line)) continue
|
|
52
|
+
// 分类列表的当前子段模式:include(新增/修改/删除)或 exclude(不修改/保留)
|
|
53
|
+
let listMode = 'include'
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
headerSkipped = true
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
// 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件
|
|
57
|
+
const subHeader = line.match(/^###\s+(.+?)\s*$/)
|
|
58
|
+
if (subHeader) {
|
|
59
|
+
listMode = /不修改|不变|保留|无变更|未变更|不改动/.test(subHeader[1]) ? 'exclude' : 'include'
|
|
40
60
|
continue
|
|
41
61
|
}
|
|
42
62
|
|
|
43
|
-
|
|
63
|
+
// 表格行
|
|
64
|
+
if (line.startsWith('|')) {
|
|
65
|
+
if (/^\|[-:\s|]+\|$/.test(line)) continue // 分隔行
|
|
66
|
+
const cells = line.split('|').slice(1, -1) // 去掉首尾空元素
|
|
67
|
+
if (cells.length < 2) continue
|
|
68
|
+
if (!headerSkipped) { headerSkipped = true; continue } // 跳过表头
|
|
44
69
|
|
|
45
|
-
|
|
46
|
-
|
|
70
|
+
const filePath = normalizeEntry(cells[1])
|
|
71
|
+
if (isExcluded(filePath)) continue
|
|
72
|
+
result.add(filePath)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
47
75
|
|
|
48
|
-
|
|
76
|
+
// 分类列表项:`- path` / `- \`path\``
|
|
77
|
+
const listItem = line.match(/^\s*-\s+(.+)/)
|
|
78
|
+
if (listItem) {
|
|
79
|
+
const filePath = normalizeEntry(listItem[1])
|
|
80
|
+
if (isExcluded(filePath)) continue
|
|
81
|
+
if (listMode === 'exclude') result.delete(filePath)
|
|
82
|
+
else result.add(filePath)
|
|
83
|
+
}
|
|
49
84
|
}
|
|
50
85
|
|
|
51
86
|
return result
|
package/src/contract-matrix.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
scanFrontendApiCalls,
|
|
16
16
|
normalizePath,
|
|
17
17
|
} from './endpoint-extractor.js'
|
|
18
|
+
import { parseTaskContracts } from './stages/plan-postcheck.js'
|
|
18
19
|
|
|
19
20
|
// ─── 关键词检测 ─────────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -218,6 +219,72 @@ export function buildConsumerInjection(changeDir, specBase, taskName, contracts)
|
|
|
218
219
|
return parts.join('\n')
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 为 consumer task 构建字段级契约注入:对比 expects_from.needs vs provider.provides.fields
|
|
224
|
+
*
|
|
225
|
+
* 让 consumer 子代理带着明确的字段清单核验上游产出:
|
|
226
|
+
* - provider 已承诺 → 编码时只使用 provides.fields,运行时缺字段上报
|
|
227
|
+
* - provider 未承诺某 needs 字段 → 标 CONTRACT_GAP,要求 stop and report(禁止 fallback 编造)
|
|
228
|
+
*
|
|
229
|
+
* 命中场景:provider task 漏实现某字段(如 DaemonRuntimeRead 缺 daemon_instance_id),
|
|
230
|
+
* consumer 若 fallback 编造 → 运行时 403/500。此处把"缺字段"暴露在子代理启动前。
|
|
231
|
+
*
|
|
232
|
+
* 注:plan-postcheck 已对账 expects_from↔provides,此处是 execute 时的二次保险,
|
|
233
|
+
* 拦截 plan-postcheck 之后 task 文件被手改、或 provider 实际实现漏字段的情况。
|
|
234
|
+
*
|
|
235
|
+
* @param {string} changeDir - changes/<name>/ 目录
|
|
236
|
+
* @param {string} taskName - consumer task(如 task-11)
|
|
237
|
+
* @returns {string|null} 注入文本,无 expects_from 时返回 null
|
|
238
|
+
*/
|
|
239
|
+
export function buildContractFieldInjection(changeDir, taskName) {
|
|
240
|
+
const consumerFile = join(changeDir, 'tasks', `${taskName}.md`)
|
|
241
|
+
if (!existsSync(consumerFile)) return null
|
|
242
|
+
const { expectsFrom } = parseTaskContracts(readFileSync(consumerFile, 'utf8'))
|
|
243
|
+
const providers = Object.keys(expectsFrom)
|
|
244
|
+
if (providers.length === 0) return null
|
|
245
|
+
|
|
246
|
+
const lines = []
|
|
247
|
+
lines.push('## Upstream Contract Fields(字段级核验)')
|
|
248
|
+
lines.push('')
|
|
249
|
+
|
|
250
|
+
for (const providerTask of providers) {
|
|
251
|
+
const providerFile = join(changeDir, 'tasks', `${providerTask}.md`)
|
|
252
|
+
let providerProvides = []
|
|
253
|
+
if (existsSync(providerFile)) {
|
|
254
|
+
providerProvides = parseTaskContracts(readFileSync(providerFile, 'utf8')).provides
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const c of expectsFrom[providerTask]) {
|
|
258
|
+
const providerEntry = providerProvides.find(p => p.contract === c.contract)
|
|
259
|
+
if (!providerEntry) {
|
|
260
|
+
lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
|
|
261
|
+
lines.push(`你需要字段 [${c.needs.join(', ')}],但 ${providerTask} 的 provides 未声明此契约。`)
|
|
262
|
+
lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先确认 ${providerTask} 是否应产出此契约。`)
|
|
263
|
+
} else {
|
|
264
|
+
const providerFields = new Set(providerEntry.fields)
|
|
265
|
+
const missing = c.needs.filter(f => !providerFields.has(f))
|
|
266
|
+
if (missing.length > 0) {
|
|
267
|
+
lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
|
|
268
|
+
lines.push(`你需要字段 [${missing.join(', ')}],但 ${providerTask}.provides 仅承诺 [${providerEntry.fields.join(', ')}]。`)
|
|
269
|
+
lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先要求 ${providerTask} 在 provides.fields 补上 [${missing.join(', ')}]。`)
|
|
270
|
+
} else {
|
|
271
|
+
lines.push(`### ✅ ${providerTask} → ${c.contract}`)
|
|
272
|
+
lines.push(`你需要的字段 [${c.needs.join(', ')}] 均在 ${providerTask}.provides 承诺内:[${providerEntry.fields.join(', ')}]。`)
|
|
273
|
+
lines.push(`编码时**只使用上述字段**;若运行时实际返回缺字段,说明 provider 实现漏了 → 上报 CONTRACT_GAP,不要 fallback。`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
lines.push('')
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
lines.push('### 字段级铁律')
|
|
281
|
+
lines.push('1. 禁止 fallback 编造:若上游返回缺字段,停止并上报 CONTRACT_GAP,不要用 `x || defaultValue` 之类的防御性回退掩盖契约破裂。')
|
|
282
|
+
lines.push('2. 只消费 provides 承诺的字段;需要新字段必须先让 provider 更新 provides。')
|
|
283
|
+
lines.push('3. 启动子代理前,先读 provider task 的 review.json / acceptance,确认其已声明完成上述契约字段。')
|
|
284
|
+
|
|
285
|
+
return lines.join('\n')
|
|
286
|
+
}
|
|
287
|
+
|
|
221
288
|
// ─── Verify 阶段:parity check ──────────────────────────────────────────
|
|
222
289
|
|
|
223
290
|
/**
|
package/src/stages/execute.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
|
+
import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjection } from '../contract-matrix.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 校验 plan.md 是否满足 execute 执行契约
|
|
@@ -430,42 +431,60 @@ function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
|
|
|
430
431
|
let contractInjection = ''
|
|
431
432
|
if (changeDir) {
|
|
432
433
|
try {
|
|
433
|
-
const { buildContractMatrix, buildConsumerInjection } = require('../contract-matrix.js')
|
|
434
434
|
const planFile = path.join(changeDir, 'plan.md')
|
|
435
435
|
if (existsSync(planFile)) {
|
|
436
436
|
const planContent = readFileSync(planFile, 'utf8')
|
|
437
|
+
// 收集本 wave 所有 task(端点注入与字段注入共用)
|
|
438
|
+
const waveTasks = wave.tasks.map((t, ti) => {
|
|
439
|
+
const num = String(t.index || (ti + 1)).padStart(2, '0')
|
|
440
|
+
return `task-${num}`
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// 1) 端点级契约(provider/consumer via buildContractMatrix)
|
|
437
444
|
const contracts = buildContractMatrix(planContent, changeDir)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const num = String(t.index || (ti + 1)).padStart(2, '0')
|
|
442
|
-
return `task-${num}`
|
|
443
|
-
})
|
|
444
|
-
const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
|
|
445
|
-
if (relevantContracts.length > 0) {
|
|
446
|
-
contractInjection = `
|
|
445
|
+
const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
|
|
446
|
+
if (relevantContracts.length > 0) {
|
|
447
|
+
contractInjection = `
|
|
447
448
|
### API Contract Matrix
|
|
448
449
|
本 Wave 存在前端/后端跨 task 契约:
|
|
449
450
|
${relevantContracts.map(c => `- **${c.consumer}** 消费 **${c.provider}** 产出的 API`).join('\n')}
|
|
450
451
|
`
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
### 子代理 ${taskName} 的契约注入
|
|
452
|
+
for (const taskName of waveTasks) {
|
|
453
|
+
const injection = buildConsumerInjection(changeDir, path.join(changeDir, '..', '..'), taskName, contracts)
|
|
454
|
+
if (injection) {
|
|
455
|
+
contractInjection += `
|
|
456
|
+
### 子代理 ${taskName} 的端点契约注入
|
|
457
457
|
为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
|
|
458
458
|
|
|
459
459
|
<contract-injection>
|
|
460
460
|
${injection}
|
|
461
461
|
</contract-injection>
|
|
462
462
|
`
|
|
463
|
-
}
|
|
464
463
|
}
|
|
465
464
|
}
|
|
466
465
|
}
|
|
466
|
+
|
|
467
|
+
// 2) 字段级契约(expects_from ↔ provides)
|
|
468
|
+
// 命中「provider 漏字段、consumer fallback 编造 → 运行时 403/500」这类 bug:
|
|
469
|
+
// 把 consumer 期望字段 vs provider 承诺字段显式注入子代理 prompt
|
|
470
|
+
for (const taskName of waveTasks) {
|
|
471
|
+
const fi = buildContractFieldInjection(changeDir, taskName)
|
|
472
|
+
if (fi) {
|
|
473
|
+
contractInjection += `
|
|
474
|
+
### 子代理 ${taskName} 的字段契约注入
|
|
475
|
+
为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
|
|
476
|
+
|
|
477
|
+
<contract-field-injection>
|
|
478
|
+
${fi}
|
|
479
|
+
</contract-field-injection>
|
|
480
|
+
`
|
|
481
|
+
}
|
|
482
|
+
}
|
|
467
483
|
}
|
|
468
|
-
} catch {
|
|
484
|
+
} catch (e) {
|
|
485
|
+
// 契约注入是 best-effort:失败不阻断 execute,只记录
|
|
486
|
+
console.warn(` ⚠️ 契约注入跳过: ${e?.message || e}`)
|
|
487
|
+
}
|
|
469
488
|
}
|
|
470
489
|
|
|
471
490
|
// 构建任务摘要(不再内联完整蓝图,减少上下文污染)
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
13
13
|
import { join as pJoin } from 'path'
|
|
14
|
+
import jsYaml from 'js-yaml'
|
|
15
|
+
import { parseFileChangeList } from '../change-list.js'
|
|
14
16
|
|
|
15
17
|
// ═══════════════════════════════════════════════════════════════
|
|
16
18
|
// 解析工具(从 plan.js 迁移)
|
|
@@ -90,6 +92,47 @@ function hasTddOrVerify(content) {
|
|
|
90
92
|
return /##\s*TDD/.test(content) || /##\s*验证/.test(content) || /##\s*Verify/.test(content)
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* 解析 task-NN.md 的跨任务契约字段 provides / expects_from
|
|
97
|
+
* 用 js-yaml 解析 frontmatter(嵌套结构,正则不可靠)。
|
|
98
|
+
* provides/expects_from 为可选字段;缺失或解析失败时返回空(不阻断)。
|
|
99
|
+
* @param {string} content - task 文件内容
|
|
100
|
+
* @returns {{ provides: Array<{contract, fields}>, expectsFrom: Record<string, Array<{contract, needs}>> }}
|
|
101
|
+
*/
|
|
102
|
+
export function parseTaskContracts(content) {
|
|
103
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
104
|
+
if (!fmMatch) return { provides: [], expectsFrom: {} }
|
|
105
|
+
let fm
|
|
106
|
+
try {
|
|
107
|
+
fm = jsYaml.load(fmMatch[1]) || {}
|
|
108
|
+
} catch {
|
|
109
|
+
// frontmatter 非合法 YAML(老格式/手写不规范)—— 当作无契约字段,不阻断
|
|
110
|
+
return { provides: [], expectsFrom: {} }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const provides = Array.isArray(fm.provides)
|
|
114
|
+
? fm.provides.map(p => ({
|
|
115
|
+
contract: String(p?.contract || ''),
|
|
116
|
+
fields: Array.isArray(p?.fields) ? p.fields.map(String) : [],
|
|
117
|
+
})).filter(p => p.contract)
|
|
118
|
+
: []
|
|
119
|
+
|
|
120
|
+
const expectsFrom = {}
|
|
121
|
+
if (fm.expects_from && typeof fm.expects_from === 'object' && !Array.isArray(fm.expects_from)) {
|
|
122
|
+
for (const [providerTask, contracts] of Object.entries(fm.expects_from)) {
|
|
123
|
+
if (!Array.isArray(contracts)) continue
|
|
124
|
+
expectsFrom[providerTask] = contracts
|
|
125
|
+
.map(c => ({
|
|
126
|
+
contract: String(c?.contract || ''),
|
|
127
|
+
needs: Array.isArray(c?.needs) ? c.needs.map(String) : [],
|
|
128
|
+
}))
|
|
129
|
+
.filter(c => c.contract)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { provides, expectsFrom }
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
// ═══════════════════════════════════════════════════════════════
|
|
94
137
|
// 核心逻辑
|
|
95
138
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -222,6 +265,171 @@ export function validateBlueprintConsistency(changeDir) {
|
|
|
222
265
|
return { ok: errors.length === 0, errors, warnings }
|
|
223
266
|
}
|
|
224
267
|
|
|
268
|
+
/**
|
|
269
|
+
* 跨任务契约校验器:对账 consumer.expects_from ↔ provider.provides
|
|
270
|
+
*
|
|
271
|
+
* 修复场景:consumer task 声明期望 provider 产出某 DTO 的某字段,
|
|
272
|
+
* 但 provider 的 provides 未承诺(或字段缺失)→ plan 阶段阻断,
|
|
273
|
+
* 避免到 execute/verify 才暴露(典型表现:前端 fallback 错误字段 → 403/500)。
|
|
274
|
+
*
|
|
275
|
+
* provides / expects_from 均为可选字段:未声明时不校验(向后兼容)。
|
|
276
|
+
* @param {string} changeDir - 变更目录
|
|
277
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[] }}
|
|
278
|
+
*/
|
|
279
|
+
export function validateCrossTaskContracts(changeDir) {
|
|
280
|
+
const errors = []
|
|
281
|
+
const warnings = []
|
|
282
|
+
|
|
283
|
+
const tasksDir = pJoin(changeDir, 'tasks')
|
|
284
|
+
if (!existsSync(tasksDir)) {
|
|
285
|
+
return { ok: true, errors, warnings }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
|
|
289
|
+
if (taskFiles.length === 0) {
|
|
290
|
+
return { ok: true, errors, warnings }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 第一遍:收集每个 task 的 provides(taskId → Map(contract → Set(fields)))
|
|
294
|
+
const providesByTask = new Map()
|
|
295
|
+
for (const file of taskFiles) {
|
|
296
|
+
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
297
|
+
const taskId = parseTaskId(content, file)
|
|
298
|
+
if (!taskId) continue
|
|
299
|
+
const { provides } = parseTaskContracts(content)
|
|
300
|
+
const contractMap = new Map()
|
|
301
|
+
for (const p of provides) {
|
|
302
|
+
contractMap.set(p.contract, new Set(p.fields))
|
|
303
|
+
}
|
|
304
|
+
providesByTask.set(taskId, contractMap)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖
|
|
308
|
+
for (const file of taskFiles) {
|
|
309
|
+
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
310
|
+
const consumerId = parseTaskId(content, file)
|
|
311
|
+
if (!consumerId) continue
|
|
312
|
+
const { expectsFrom } = parseTaskContracts(content)
|
|
313
|
+
|
|
314
|
+
for (const [providerTask, contracts] of Object.entries(expectsFrom)) {
|
|
315
|
+
if (!providesByTask.has(providerTask)) {
|
|
316
|
+
for (const c of contracts) {
|
|
317
|
+
errors.push(`${consumerId}: expects_from 引用了不存在的 ${providerTask}(contract "${c.contract}", needs [${c.needs.join(', ')}])`)
|
|
318
|
+
}
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const providerContracts = providesByTask.get(providerTask)
|
|
323
|
+
for (const c of contracts) {
|
|
324
|
+
const providerFields = providerContracts.get(c.contract)
|
|
325
|
+
if (providerFields === undefined) {
|
|
326
|
+
errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${c.needs.join(', ')}] — ${providerTask} 的 provides 未声明此契约`)
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const missing = c.needs.filter(f => !providerFields.has(f))
|
|
330
|
+
if (missing.length > 0) {
|
|
331
|
+
errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${missing.join(', ')}] — ${providerTask}.provides 仅含 [${[...providerFields].join(', ')}]`)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── 路径匹配工具(与 change-list.js 的 normalizeEntry 同源语义)─────────
|
|
341
|
+
// allowed_paths 真实写法常带行内注释(`src/worktree.js (新增)`),匹配前必须归一化。
|
|
342
|
+
function normalizePath(raw) {
|
|
343
|
+
if (!raw) return ''
|
|
344
|
+
return raw
|
|
345
|
+
.replace(/`/g, '')
|
|
346
|
+
.replace(/\s*([^)]*)\s*$/, '')
|
|
347
|
+
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
348
|
+
.replace(/\\/g, '/')
|
|
349
|
+
.replace(/\/+$/, '')
|
|
350
|
+
.trim()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function globMatch(str, pattern) {
|
|
354
|
+
if (!pattern.includes('*')) return false
|
|
355
|
+
const re = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '$'
|
|
356
|
+
try { return new RegExp(re).test(str) } catch { return false }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 双向容差匹配:design 清单文件 vs task allowed_paths
|
|
361
|
+
* 命中条件(任一):完全相等 / 目录前缀包含(双向)/ glob 通配(双向)
|
|
362
|
+
*/
|
|
363
|
+
function pathMatches(designFile, allowedPath) {
|
|
364
|
+
const a = normalizePath(designFile)
|
|
365
|
+
const b = normalizePath(allowedPath)
|
|
366
|
+
if (!a || !b) return false
|
|
367
|
+
if (a === b) return true
|
|
368
|
+
if (a.startsWith(b + '/') || b.startsWith(a + '/')) return true
|
|
369
|
+
if (globMatch(a, b) || globMatch(b, a)) return true
|
|
370
|
+
return false
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* design.md 文件变更清单 → tasks allowed_paths 覆盖对账
|
|
375
|
+
*
|
|
376
|
+
* 修复场景:design.md 声明要改某源码文件(如 access-guide UI),但没有任何 task 的
|
|
377
|
+
* allowed_paths 覆盖它 → execute 子代理被 allowed_paths 锁死不能碰它 → UI 没改
|
|
378
|
+
* (「页面还是错的」),而 verify 一路对照 task 卡片循环验证照样 PASS。
|
|
379
|
+
*
|
|
380
|
+
* 在 plan-postcheck(execute 前)确定性拦截:design 清单中每个源码文件必须被
|
|
381
|
+
* 至少一个 task 的 allowed_paths 覆盖(前缀 / glob 容差匹配)。
|
|
382
|
+
*
|
|
383
|
+
* fail-open 边界(不阻断):design.md 不存在、无清单章节、解析为空、无 tasks/、
|
|
384
|
+
* 无 allowed_paths —— 保持与 none/light 级别及老变更向后兼容。
|
|
385
|
+
*
|
|
386
|
+
* @param {string} changeDir - 变更目录
|
|
387
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[], designFiles: string[], uncovered: string[] }}
|
|
388
|
+
*/
|
|
389
|
+
export function validateDesignFileCoverage(changeDir) {
|
|
390
|
+
const errors = []
|
|
391
|
+
const warnings = []
|
|
392
|
+
|
|
393
|
+
const designPath = pJoin(changeDir, 'design.md')
|
|
394
|
+
if (!existsSync(designPath)) {
|
|
395
|
+
return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const designFiles = [...parseFileChangeList(designPath)]
|
|
399
|
+
if (designFiles.length === 0) {
|
|
400
|
+
warnings.push('design.md 未找到「文件变更清单」或清单为空,跳过文件覆盖对账')
|
|
401
|
+
return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const tasksDir = pJoin(changeDir, 'tasks')
|
|
405
|
+
if (!existsSync(tasksDir)) {
|
|
406
|
+
return { ok: true, errors, warnings, designFiles, uncovered: [] }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
|
|
410
|
+
const allAllowed = []
|
|
411
|
+
for (const file of taskFiles) {
|
|
412
|
+
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
413
|
+
allAllowed.push(...parseAllowedPaths(content))
|
|
414
|
+
}
|
|
415
|
+
if (allAllowed.length === 0) {
|
|
416
|
+
return { ok: true, errors, warnings, designFiles, uncovered: [] }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const uncovered = designFiles.filter(df => !allAllowed.some(ap => pathMatches(df, ap)))
|
|
420
|
+
if (uncovered.length > 0) {
|
|
421
|
+
errors.push(
|
|
422
|
+
`design.md 文件变更清单中 ${uncovered.length} 个文件未被任何 task 的 allowed_paths 覆盖:\n` +
|
|
423
|
+
uncovered.map(f => ` • ${f}`).join('\n') +
|
|
424
|
+
`\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。` +
|
|
425
|
+
`\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,` +
|
|
426
|
+
`或在 design.md「不修改文件」章节说明不改原因。`
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { ok: errors.length === 0, errors, warnings, designFiles, uncovered }
|
|
431
|
+
}
|
|
432
|
+
|
|
225
433
|
/**
|
|
226
434
|
* Plan 可行性校验器(本地代码证明 execute 前置条件)
|
|
227
435
|
* 检查 TaskCard 的完整性和可行性
|
|
@@ -426,6 +634,32 @@ export async function executePlanPostcheck(context) {
|
|
|
426
634
|
for (const w of feasibility.warnings) console.warn(` - ${w}`)
|
|
427
635
|
}
|
|
428
636
|
|
|
637
|
+
// ── 1c. 跨任务契约校验 ──
|
|
638
|
+
// 对账 consumer.expects_from ↔ provider.provides,拦截「consumer 期望字段
|
|
639
|
+
// 但 provider 未承诺」的契约断裂(避免到 execute/verify 才暴露成 403/500)
|
|
640
|
+
const crossTask = validateCrossTaskContracts(changeDir)
|
|
641
|
+
if (crossTask.errors.length > 0) {
|
|
642
|
+
console.error('\n❌ 跨任务契约校验失败(consumer 期望的字段未被 provider 承诺):')
|
|
643
|
+
for (const err of crossTask.errors) console.error(` - ${err}`)
|
|
644
|
+
console.error('\n 修复方式:要么在 provider task 的 provides.fields 补上缺失字段,')
|
|
645
|
+
console.error(' 要么修正 consumer task 的 expects_from.needs(确认依赖是否真实)。')
|
|
646
|
+
throw new Error('planPostcheck: cross-task contract check failed')
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ── 1d. design 文件覆盖对账 ──
|
|
650
|
+
// design.md 清单中的每个源码文件必须被某 task 的 allowed_paths 覆盖,
|
|
651
|
+
// 否则 execute 子代理无权改它 → 漏改(典型表现:「页面还是错的」)。
|
|
652
|
+
const coverage = validateDesignFileCoverage(changeDir)
|
|
653
|
+
for (const w of coverage.warnings) console.warn(`\n ⚠️ ${w}`)
|
|
654
|
+
if (coverage.errors.length > 0) {
|
|
655
|
+
console.error('\n❌ design.md 文件覆盖对账失败(清单中的文件未被任何 task 覆盖):')
|
|
656
|
+
for (const err of coverage.errors) console.error(` - ${err}`)
|
|
657
|
+
throw new Error('planPostcheck: design file coverage check failed')
|
|
658
|
+
}
|
|
659
|
+
if (coverage.designFiles.length > 0 && coverage.uncovered.length === 0) {
|
|
660
|
+
console.log(` ✅ design.md ${coverage.designFiles.length} 个文件全部被 task allowed_paths 覆盖`)
|
|
661
|
+
}
|
|
662
|
+
|
|
429
663
|
// ── 2. Wave 重排 ──
|
|
430
664
|
const tasksDir = pJoin(changeDir, 'tasks')
|
|
431
665
|
if (existsSync(tasksDir)) {
|
package/src/stages/plan.js
CHANGED
|
@@ -358,6 +358,8 @@ full 计划的约束:
|
|
|
358
358
|
- [ ] plan.md 与 design.md 的文件变更清单一致
|
|
359
359
|
- [ ] 如果涉及构造函数/接口/DTO/client 方法变更,是否搜索了所有调用点并纳入任务范围?
|
|
360
360
|
- [ ] 调用点搜索命令的输出是否记录在 plan.md 或 task-NN.md 中?
|
|
361
|
+
- [ ] 跨任务契约自检:若 task-A 的产出(接口/DTO/响应)被 task-B 消费,consumer 是否在 TaskCard expects_from 里声明所需字段、provider 是否在 provides 里承诺这些字段、两边字段是否一致?(plan-postcheck 会硬校验,此处先自查)
|
|
362
|
+
- [ ] 文件覆盖自检:design.md「文件变更清单」中的每个源码文件,是否都被至少一个 task 的 allowed_paths 覆盖?(plan-postcheck 会硬校验,漏覆盖 = execute 必然漏改,此处先自查)
|
|
361
363
|
- [ ] 如果有 Mermaid 图,依赖关系确实非平凡(非线性/非全并行)
|
|
362
364
|
- [ ] 没有泛泛风险分析(如"需要充分测试")
|
|
363
365
|
|
|
@@ -407,6 +409,13 @@ requirement_ids: [FR-XX]
|
|
|
407
409
|
decision_ids: [D-XXX@vN]
|
|
408
410
|
allowed_paths:
|
|
409
411
|
- frontend/src/lib/errors.ts
|
|
412
|
+
provides: # 可选。仅当本 task 给其他 task 提供接口/DTO/响应时填
|
|
413
|
+
- contract: <DTO或响应类型名> # 如 DaemonRuntimeRead
|
|
414
|
+
fields: [field_a, field_b]
|
|
415
|
+
expects_from: # 可选。仅当本 task 消费其他 task 的契约时填
|
|
416
|
+
<provider-task-id>: # 如 task-05(占位符,不要照抄)
|
|
417
|
+
- contract: <DTO或响应类型名>
|
|
418
|
+
needs: [field_a] # 必须从该 provider 拿到的字段
|
|
410
419
|
goal: >
|
|
411
420
|
一句话说明这个 task 要做什么、为什么。
|
|
412
421
|
implementation:
|
|
@@ -433,6 +442,9 @@ TaskCard 格式规则(必须严格遵守):
|
|
|
433
442
|
- verify: 列表,实际可执行的命令
|
|
434
443
|
- constraints: 列表,明确边界(含 brownfield 兼容、异常处理)
|
|
435
444
|
- 不需要:修改文件章节、覆盖来源章节、接口定义章节、TDD 步骤章节、参考章节
|
|
445
|
+
- provides / expects_from 是可选字段:仅当跨 task 契约(一个 task 的接口/DTO/响应被另一个 task 消费)时才填,单 task 或无对外接口场景留空即可
|
|
446
|
+
- 填写后 plan-postcheck 会做硬对账:consumer 的每个 expects_from[provider].needs 字段必须在对应 provider 的 provides.fields 里,否则 plan 阶段阻断(不进入 execute)
|
|
447
|
+
- 不要把内部实现字段塞进 provides;只暴露给其他 task 的对外契约形状
|
|
436
448
|
- 如果存在 decisions.md,无法覆盖的 D-xxx@vN 在 constraints 中标注
|
|
437
449
|
- 写完后用 Write tool 保存到文件
|
|
438
450
|
\`\`\``
|
|
@@ -471,6 +483,7 @@ ${subagentPrompts}
|
|
|
471
483
|
- **一致性自查**:
|
|
472
484
|
- allowed_paths 有无冲突
|
|
473
485
|
- depends_on 与 plan.md Wave 分组是否一致
|
|
486
|
+
- provides/expects_from 契约自洽:每个 expects_from[provider].needs 字段都在该 provider task 的 provides.fields 里(plan-postcheck 会硬校验,这里提前自查)
|
|
474
487
|
- 如发现矛盾,列出问题清单,不要自动修复`
|
|
475
488
|
|
|
476
489
|
return {
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
author: qinyi
|
|
3
|
-
created_at: 2026-06-19 00:45:00
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Brainstorm → Plan Contract
|
|
7
|
-
|
|
8
|
-
## 核心契约
|
|
9
|
-
|
|
10
|
-
`design.md` 是 plan 阶段的**主要设计输入**。plan 不应该在空的或缺少关键决策的 design.md 上生成任务。
|
|
11
|
-
|
|
12
|
-
## design.md 结构要求
|
|
13
|
-
|
|
14
|
-
### 必须包含(error — 阻断 plan)
|
|
15
|
-
|
|
16
|
-
| # | 章节 | 匹配关键词 |
|
|
17
|
-
|---|------|-----------|
|
|
18
|
-
| 1 | 目标/背景/问题描述 | 目标、goal、objective、背景、background、问题、problem、purpose、目的 |
|
|
19
|
-
| 2 | 范围/总体方案/设计 | 范围、scope、总体方案、方案、approach、solution、设计、design |
|
|
20
|
-
| 3 | 决策/方案选择 | 决策、decision、选择、choice、方案选择、D-xxx@vN(decisions.md 引用) |
|
|
21
|
-
|
|
22
|
-
### 建议包含(warning — 不阻断 plan)
|
|
23
|
-
|
|
24
|
-
| # | 章节 | 匹配关键词 |
|
|
25
|
-
|---|------|-----------|
|
|
26
|
-
| 4 | 非目标/Non-goals | 非目标、non-goals、不做、out of scope |
|
|
27
|
-
| 5 | 约束/风险/Trade-off | 约束、constraint、风险、risk、trade-off |
|
|
28
|
-
| 6 | 文件变更清单 | 文件变更、变更清单、changed files |
|
|
29
|
-
|
|
30
|
-
## 校验规则
|
|
31
|
-
|
|
32
|
-
plan 启动时(第一个步骤执行前)调用 `validateDesignForPlan(designContent)`:
|
|
33
|
-
|
|
34
|
-
| 结果 | 行为 |
|
|
35
|
-
|------|------|
|
|
36
|
-
| 全部通过 | 正常进入 plan |
|
|
37
|
-
| 有 warning | 继续执行,展示警告 |
|
|
38
|
-
| 有 error | fail-fast,提示修复 design.md |
|
|
39
|
-
|
|
40
|
-
## 第一版设计原则
|
|
41
|
-
|
|
42
|
-
- **轻量 markdown 契约**:检查标题和关键词,不强 schema
|
|
43
|
-
- **关键词宽泛**:中英文都支持
|
|
44
|
-
- **decisions.md 引用也算决策**:`D-xxx@vN` 或 `decisions.md` 引用即满足决策检查
|
|
45
|
-
- **不做 brainstorm postcheck 阻断**:brainstorm 完成时不校验此契约(brainstorm 可以产出不完整的 design.md),只在 plan 启动时校验
|
|
46
|
-
|
|
47
|
-
## 错误处理
|
|
48
|
-
|
|
49
|
-
| 场景 | 行为 |
|
|
50
|
-
|------|------|
|
|
51
|
-
| design.md 不存在 | 不校验(向后兼容,plan 可以独立运行) |
|
|
52
|
-
| design.md 空 | fail-fast |
|
|
53
|
-
| 缺目标/背景 | fail-fast |
|
|
54
|
-
| 缺范围/方案 | fail-fast |
|
|
55
|
-
| 缺决策 | fail-fast |
|
|
56
|
-
| 缺非目标/约束/文件清单 | warning,继续执行 |
|
|
57
|
-
|
|
58
|
-
## 完整契约链
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
brainstorm → design.md → [Plan Contract 校验] → plan → plan.md → [Execute Contract 校验] → execute
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
每个阶段启动前都校验上游产物,形成双重保险。
|