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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/change-list.js +51 -16
  3. package/src/contract-matrix.js +67 -0
  4. package/src/stages/execute.js +37 -18
  5. package/src/stages/plan-postcheck.js +234 -0
  6. package/src/stages/plan.js +13 -0
  7. package/docs/brainstorm-plan-contract.md +0 -64
  8. package/docs/plan-execute-contract.md +0 -123
  9. package/docs/platform-scan-protocol.md +0 -298
  10. package/docs/revision-mode.md +0 -115
  11. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +0 -99
  12. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +0 -223
  13. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +0 -167
  14. package/docs/sillyspec/file-lifecycle/storage-and-state.md +0 -148
  15. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +0 -211
  16. package/docs/sillyspec/file-lifecycle.md +0 -143
  17. package/docs/workflow-contract-regression.md +0 -106
  18. package/docs/worktree-isolation.md +0 -252
  19. package/test/brainstorm-plan-contract.test.mjs +0 -273
  20. package/test/check-syntax.mjs +0 -26
  21. package/test/cli-top-level-aliases.test.mjs +0 -174
  22. package/test/contract-artifacts.test.mjs +0 -323
  23. package/test/decision-ref-version.mjs +0 -85
  24. package/test/decision-supersede.test.mjs +0 -277
  25. package/test/knowledge-match.test.mjs +0 -231
  26. package/test/plan-execute-contract.test.mjs +0 -357
  27. package/test/plan-optimization.test.mjs +0 -572
  28. package/test/platform-artifacts.test.mjs +0 -190
  29. package/test/platform-failure-samples.test.mjs +0 -199
  30. package/test/platform-recovery-chain.test.mjs +0 -179
  31. package/test/platform-recovery.test.mjs +0 -167
  32. package/test/platform-scan-p0.test.mjs +0 -186
  33. package/test/quick-recommend.test.mjs +0 -146
  34. package/test/revision-v1.test.mjs +0 -1145
  35. package/test/run-sanitize-project-name.test.mjs +0 -51
  36. package/test/run-scan-postcheck-fail.test.mjs +0 -64
  37. package/test/run-scan-project-parse.test.mjs +0 -200
  38. package/test/run-tests.mjs +0 -48
  39. package/test/runtime-cleanup-keeps-worktree.test.mjs +0 -107
  40. package/test/scan-docs-yaml-placeholders.test.mjs +0 -84
  41. package/test/scan-knowledge.test.mjs +0 -175
  42. package/test/scan-paths.test.mjs +0 -68
  43. package/test/scan-postcheck-project-priority.test.mjs +0 -85
  44. package/test/scan-postcheck.test.mjs +0 -197
  45. package/test/scan-workflow-anyfailed-block.test.mjs +0 -52
  46. package/test/spec-dir.test.mjs +0 -206
  47. package/test/stage-contract-failed-post-check.test.mjs +0 -102
  48. package/test/stage-contract.test.mjs +0 -301
  49. package/test/stage-definitions.test.mjs +0 -39
  50. package/test/wait-gates.test.mjs +0 -501
  51. package/test/workflow-spec-base.test.mjs +0 -142
  52. package/test/worktree-deps-provision.test.mjs +0 -148
  53. package/test/worktree-guard.test.mjs +0 -136
  54. package/test/worktree-native-overlay.test.mjs +0 -188
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.20.7",
3
+ "version": "3.21.0",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -1,7 +1,28 @@
1
1
  import { readFileSync, existsSync } from 'fs'
2
2
 
3
3
  /**
4
- * design.md 解析文件变更清单
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
- for (const line of lines) {
31
- // 跳过分隔行和非表格行
32
- if (!line.startsWith('|') || /^\|[-:\s|]+\|$/.test(line)) continue
52
+ // 分类列表的当前子段模式:include(新增/修改/删除)或 exclude(不修改/保留)
53
+ let listMode = 'include'
33
54
 
34
- const cells = line.split('|').slice(1, -1) // 去掉首尾空元素
35
- if (cells.length < 2) continue
36
-
37
- // 跳过 header 行(包含「文件路径」的表头)
38
- if (!headerSkipped) {
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
- const filePath = cells[1].trim().replace(/^`|`$/g, '')
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
- // 忽略空路径、注释、.sillyspec/ 内的路径
46
- if (!filePath || filePath === '—' || filePath === '-' || filePath.startsWith('.sillyspec/')) continue
70
+ const filePath = normalizeEntry(cells[1])
71
+ if (isExcluded(filePath)) continue
72
+ result.add(filePath)
73
+ continue
74
+ }
47
75
 
48
- result.add(filePath)
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
@@ -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
  /**
@@ -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
- if (contracts.length > 0) {
439
- // 收集本 wave 所有 task 的注入内容
440
- const waveTasks = wave.tasks.map((t, ti) => {
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
- // 为每个 consumer task 生成详细注入
452
- for (const taskName of waveTasks) {
453
- const injection = buildConsumerInjection(changeDir, join(changeDir, '..', '..'), taskName, contracts)
454
- if (injection) {
455
- contractInjection += `
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)) {
@@ -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
- 每个阶段启动前都校验上游产物,形成双重保险。