sillyspec 3.27.1 → 3.27.5

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 (224) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +8 -0
  2. package/.claude/skills/sillyspec-quick/SKILL.md +2 -1
  3. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-archive/SKILL.md +87 -0
  4. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-auto/SKILL.md +93 -0
  5. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -0
  6. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-commit/SKILL.md +67 -0
  7. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-continue/SKILL.md +47 -0
  8. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-doctor/SKILL.md +76 -0
  9. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-execute/SKILL.md +214 -0
  10. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-explore/SKILL.md +102 -0
  11. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-export/SKILL.md +57 -0
  12. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-init/SKILL.md +177 -0
  13. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-knowledge/SKILL.md +270 -0
  14. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-plan/SKILL.md +156 -0
  15. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-quick/SKILL.md +116 -0
  16. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-resume/SKILL.md +70 -0
  17. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-scan/SKILL.md +79 -0
  18. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-state/SKILL.md +64 -0
  19. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-status/SKILL.md +54 -0
  20. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-verify/SKILL.md +87 -0
  21. package/.claude/worktrees/sillyspec-data-model-research/.claude/skills/sillyspec-workspace/SKILL.md +175 -0
  22. package/.claude/worktrees/sillyspec-data-model-research/.gitattributes +2 -0
  23. package/.claude/worktrees/sillyspec-data-model-research/.husky/pre-push +20 -0
  24. package/.claude/worktrees/sillyspec-data-model-research/README.md +203 -0
  25. package/.claude/worktrees/sillyspec-data-model-research/SKILL.md +91 -0
  26. package/.claude/worktrees/sillyspec-data-model-research/bin/sillyspec.js +2 -0
  27. package/.claude/worktrees/sillyspec-data-model-research/logo.jpg +0 -0
  28. package/.claude/worktrees/sillyspec-data-model-research/package-lock.json +977 -0
  29. package/.claude/worktrees/sillyspec-data-model-research/package.json +39 -0
  30. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -0
  31. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -0
  32. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/dist/favicon.jpg +0 -0
  33. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/dist/index.html +16 -0
  34. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/dist/logo.jpg +0 -0
  35. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/dist/prototype-dashboard.html +836 -0
  36. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/dist/prototype-overview.html +256 -0
  37. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/package.json +25 -0
  38. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/server/executor.js +95 -0
  39. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/server/index.js +679 -0
  40. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/server/parser.js +526 -0
  41. package/.claude/worktrees/sillyspec-data-model-research/packages/dashboard/server/watcher.js +357 -0
  42. package/.claude/worktrees/sillyspec-data-model-research/src/agent-session-log.js +803 -0
  43. package/.claude/worktrees/sillyspec-data-model-research/src/change-list.js +290 -0
  44. package/.claude/worktrees/sillyspec-data-model-research/src/change-risk-profile.js +270 -0
  45. package/.claude/worktrees/sillyspec-data-model-research/src/check-primitives.js +65 -0
  46. package/.claude/worktrees/sillyspec-data-model-research/src/classify-change.js +109 -0
  47. package/.claude/worktrees/sillyspec-data-model-research/src/commit-suggest.js +202 -0
  48. package/.claude/worktrees/sillyspec-data-model-research/src/config-cat.js +88 -0
  49. package/.claude/worktrees/sillyspec-data-model-research/src/config-schema.js +328 -0
  50. package/.claude/worktrees/sillyspec-data-model-research/src/constants.js +97 -0
  51. package/.claude/worktrees/sillyspec-data-model-research/src/contract-matrix.js +480 -0
  52. package/.claude/worktrees/sillyspec-data-model-research/src/datetime.js +20 -0
  53. package/.claude/worktrees/sillyspec-data-model-research/src/db-engine.js +55 -0
  54. package/.claude/worktrees/sillyspec-data-model-research/src/db.js +381 -0
  55. package/.claude/worktrees/sillyspec-data-model-research/src/decision-distill.js +517 -0
  56. package/.claude/worktrees/sillyspec-data-model-research/src/dispatch/backends/local-agent.js +155 -0
  57. package/.claude/worktrees/sillyspec-data-model-research/src/dispatch/backends/sillyhub-mcp.js +245 -0
  58. package/.claude/worktrees/sillyspec-data-model-research/src/dispatch/probe.js +246 -0
  59. package/.claude/worktrees/sillyspec-data-model-research/src/dispatch/strategy.js +121 -0
  60. package/.claude/worktrees/sillyspec-data-model-research/src/docs-check.js +825 -0
  61. package/.claude/worktrees/sillyspec-data-model-research/src/docs-debt.js +393 -0
  62. package/.claude/worktrees/sillyspec-data-model-research/src/docs-gate.js +107 -0
  63. package/.claude/worktrees/sillyspec-data-model-research/src/doctor-diagnostics.js +1099 -0
  64. package/.claude/worktrees/sillyspec-data-model-research/src/endpoint-extractor.js +420 -0
  65. package/.claude/worktrees/sillyspec-data-model-research/src/foreign-declared.js +78 -0
  66. package/.claude/worktrees/sillyspec-data-model-research/src/fs-atomic.js +73 -0
  67. package/.claude/worktrees/sillyspec-data-model-research/src/git-helper.js +150 -0
  68. package/.claude/worktrees/sillyspec-data-model-research/src/hooks/claude-pre-tool-use.cjs +130 -0
  69. package/.claude/worktrees/sillyspec-data-model-research/src/hooks/worktree-guard.js +892 -0
  70. package/.claude/worktrees/sillyspec-data-model-research/src/index.js +2798 -0
  71. package/.claude/worktrees/sillyspec-data-model-research/src/init.js +626 -0
  72. package/.claude/worktrees/sillyspec-data-model-research/src/knowledge-match.js +231 -0
  73. package/.claude/worktrees/sillyspec-data-model-research/src/local-detect.js +128 -0
  74. package/.claude/worktrees/sillyspec-data-model-research/src/local-register.js +104 -0
  75. package/.claude/worktrees/sillyspec-data-model-research/src/machine-interface.js +543 -0
  76. package/.claude/worktrees/sillyspec-data-model-research/src/migrate.js +127 -0
  77. package/.claude/worktrees/sillyspec-data-model-research/src/module-impact.js +166 -0
  78. package/.claude/worktrees/sillyspec-data-model-research/src/module-resolve.js +222 -0
  79. package/.claude/worktrees/sillyspec-data-model-research/src/modules.js +580 -0
  80. package/.claude/worktrees/sillyspec-data-model-research/src/progress/change-registry.js +336 -0
  81. package/.claude/worktrees/sillyspec-data-model-research/src/progress/consistency-doctor.js +355 -0
  82. package/.claude/worktrees/sillyspec-data-model-research/src/progress/shared.js +36 -0
  83. package/.claude/worktrees/sillyspec-data-model-research/src/progress/stage-machine.js +594 -0
  84. package/.claude/worktrees/sillyspec-data-model-research/src/progress/step-store.js +265 -0
  85. package/.claude/worktrees/sillyspec-data-model-research/src/progress.js +1304 -0
  86. package/.claude/worktrees/sillyspec-data-model-research/src/quick-recommend.js +115 -0
  87. package/.claude/worktrees/sillyspec-data-model-research/src/quicklog.js +852 -0
  88. package/.claude/worktrees/sillyspec-data-model-research/src/review-tier.js +77 -0
  89. package/.claude/worktrees/sillyspec-data-model-research/src/run/command.js +1595 -0
  90. package/.claude/worktrees/sillyspec-data-model-research/src/run/complete-handlers.js +1552 -0
  91. package/.claude/worktrees/sillyspec-data-model-research/src/run/complete.js +1259 -0
  92. package/.claude/worktrees/sillyspec-data-model-research/src/run/concurrent-detect.js +156 -0
  93. package/.claude/worktrees/sillyspec-data-model-research/src/run/gates.js +1084 -0
  94. package/.claude/worktrees/sillyspec-data-model-research/src/run/multi-repo-context.js +208 -0
  95. package/.claude/worktrees/sillyspec-data-model-research/src/run/next.js +128 -0
  96. package/.claude/worktrees/sillyspec-data-model-research/src/run/prompt.js +843 -0
  97. package/.claude/worktrees/sillyspec-data-model-research/src/run/quick-audit.js +186 -0
  98. package/.claude/worktrees/sillyspec-data-model-research/src/run/scan-profile.js +269 -0
  99. package/.claude/worktrees/sillyspec-data-model-research/src/run/shared.js +1562 -0
  100. package/.claude/worktrees/sillyspec-data-model-research/src/run/stage.js +584 -0
  101. package/.claude/worktrees/sillyspec-data-model-research/src/run.js +23 -0
  102. package/.claude/worktrees/sillyspec-data-model-research/src/scan-diff.js +388 -0
  103. package/.claude/worktrees/sillyspec-data-model-research/src/scan-postcheck.js +512 -0
  104. package/.claude/worktrees/sillyspec-data-model-research/src/scan-staleness.js +95 -0
  105. package/.claude/worktrees/sillyspec-data-model-research/src/setup.js +414 -0
  106. package/.claude/worktrees/sillyspec-data-model-research/src/sillyhub-mcp/client.js +597 -0
  107. package/.claude/worktrees/sillyspec-data-model-research/src/sillyhub-mcp/config.js +62 -0
  108. package/.claude/worktrees/sillyspec-data-model-research/src/spec-dir-typo.js +50 -0
  109. package/.claude/worktrees/sillyspec-data-model-research/src/spec-sync.js +358 -0
  110. package/.claude/worktrees/sillyspec-data-model-research/src/stage-contract-engine.js +171 -0
  111. package/.claude/worktrees/sillyspec-data-model-research/src/stage-contract-spec.js +550 -0
  112. package/.claude/worktrees/sillyspec-data-model-research/src/stage-contract.js +889 -0
  113. package/.claude/worktrees/sillyspec-data-model-research/src/stage-review.js +597 -0
  114. package/.claude/worktrees/sillyspec-data-model-research/src/stages/archive.js +199 -0
  115. package/.claude/worktrees/sillyspec-data-model-research/src/stages/brainstorm-auto.js +236 -0
  116. package/.claude/worktrees/sillyspec-data-model-research/src/stages/brainstorm.js +617 -0
  117. package/.claude/worktrees/sillyspec-data-model-research/src/stages/cmd-existence.js +145 -0
  118. package/.claude/worktrees/sillyspec-data-model-research/src/stages/doctor.js +540 -0
  119. package/.claude/worktrees/sillyspec-data-model-research/src/stages/execute.js +1275 -0
  120. package/.claude/worktrees/sillyspec-data-model-research/src/stages/explore.js +43 -0
  121. package/.claude/worktrees/sillyspec-data-model-research/src/stages/index.js +29 -0
  122. package/.claude/worktrees/sillyspec-data-model-research/src/stages/knowledge.js +514 -0
  123. package/.claude/worktrees/sillyspec-data-model-research/src/stages/plan-postcheck.js +1344 -0
  124. package/.claude/worktrees/sillyspec-data-model-research/src/stages/plan.js +650 -0
  125. package/.claude/worktrees/sillyspec-data-model-research/src/stages/quick.js +167 -0
  126. package/.claude/worktrees/sillyspec-data-model-research/src/stages/scan.js +608 -0
  127. package/.claude/worktrees/sillyspec-data-model-research/src/stages/status.js +64 -0
  128. package/.claude/worktrees/sillyspec-data-model-research/src/stages/verify.js +246 -0
  129. package/.claude/worktrees/sillyspec-data-model-research/src/sync.js +1470 -0
  130. package/.claude/worktrees/sillyspec-data-model-research/src/task-review.js +1498 -0
  131. package/.claude/worktrees/sillyspec-data-model-research/src/taskcard.js +210 -0
  132. package/.claude/worktrees/sillyspec-data-model-research/src/verify-postcheck.js +1718 -0
  133. package/.claude/worktrees/sillyspec-data-model-research/src/verify-probes.js +277 -0
  134. package/.claude/worktrees/sillyspec-data-model-research/src/version.js +19 -0
  135. package/.claude/worktrees/sillyspec-data-model-research/src/workflow.js +600 -0
  136. package/.claude/worktrees/sillyspec-data-model-research/src/workspace.js +125 -0
  137. package/.claude/worktrees/sillyspec-data-model-research/src/worktree-apply.js +1446 -0
  138. package/.claude/worktrees/sillyspec-data-model-research/src/worktree-deps.js +576 -0
  139. package/.claude/worktrees/sillyspec-data-model-research/src/worktree.js +1715 -0
  140. package/.claude/worktrees/sillyspec-data-model-research/templates/claude-instruction.md +23 -0
  141. package/.claude/worktrees/sillyspec-data-model-research/templates/prompts/taskcard-rules.md +28 -0
  142. package/.claude/worktrees/sillyspec-data-model-research/templates/prompts/testcase-design.md +12 -0
  143. package/.claude/worktrees/sillyspec-data-model-research/templates/prompts/verify-probes.md +32 -0
  144. package/.claude/worktrees/sillyspec-data-model-research/templates/workflows/archive-impact.yaml +79 -0
  145. package/.claude/worktrees/sillyspec-data-model-research/templates/workflows/scan-docs.yaml +132 -0
  146. package/README.md +1 -1
  147. package/eval/README.md +86 -0
  148. package/eval/eval.config.json +28 -0
  149. package/eval/lib/adapters/cli.mjs +63 -0
  150. package/eval/lib/adapters/dryrun.mjs +27 -0
  151. package/eval/lib/config.mjs +82 -0
  152. package/eval/lib/db.mjs +76 -0
  153. package/eval/lib/report.mjs +120 -0
  154. package/eval/lib/runner.mjs +157 -0
  155. package/eval/lib/tasks.mjs +73 -0
  156. package/eval/lib/util.mjs +107 -0
  157. package/eval/lib/workdir.mjs +30 -0
  158. package/eval/run.mjs +83 -0
  159. package/eval/tasks/001-slugify/files/package.json +6 -0
  160. package/eval/tasks/001-slugify/solution/src/slugify.mjs +26 -0
  161. package/eval/tasks/001-slugify/task.json +6 -0
  162. package/eval/tasks/001-slugify/verify.mjs +28 -0
  163. package/eval/tasks/002-each-day/files/package.json +6 -0
  164. package/eval/tasks/002-each-day/files/src/dates.mjs +4 -0
  165. package/eval/tasks/002-each-day/solution/src/dates.mjs +30 -0
  166. package/eval/tasks/002-each-day/task.json +6 -0
  167. package/eval/tasks/002-each-day/verify.mjs +31 -0
  168. package/eval/tasks/003-word-report/files/data/notes.txt +5 -0
  169. package/eval/tasks/003-word-report/files/package.json +6 -0
  170. package/eval/tasks/003-word-report/solution/bin/report.mjs +36 -0
  171. package/eval/tasks/003-word-report/task.json +6 -0
  172. package/eval/tasks/003-word-report/verify.mjs +38 -0
  173. package/package.json +4 -2
  174. package/src/agent-session-log.js +803 -0
  175. package/src/change-list.js +22 -2
  176. package/src/change-risk-profile.js +83 -14
  177. package/src/config-schema.js +18 -3
  178. package/src/contract-matrix.js +153 -8
  179. package/src/datetime.js +20 -0
  180. package/src/decision-distill.js +517 -0
  181. package/src/dispatch/backends/local-agent.js +4 -4
  182. package/src/docs-check.js +227 -0
  183. package/src/docs-debt.js +187 -24
  184. package/src/doctor-diagnostics.js +102 -2
  185. package/src/foreign-declared.js +78 -0
  186. package/src/index.js +99 -10
  187. package/src/init.js +9 -7
  188. package/src/knowledge-match.js +107 -6
  189. package/src/local-register.js +7 -1
  190. package/src/module-resolve.js +12 -1
  191. package/src/modules.js +2 -1
  192. package/src/plan-adopt-waves.js +126 -0
  193. package/src/quicklog.js +61 -7
  194. package/src/run/command.js +139 -20
  195. package/src/run/complete-handlers.js +48 -19
  196. package/src/run/complete.js +43 -3
  197. package/src/run/gates.js +87 -22
  198. package/src/run/prompt.js +84 -42
  199. package/src/run/quick-audit.js +28 -0
  200. package/src/run/shared.js +176 -11
  201. package/src/run/stage.js +23 -0
  202. package/src/run.js +2 -2
  203. package/src/scan-postcheck.js +30 -17
  204. package/src/stage-contract-spec.js +2 -1
  205. package/src/stage-contract.js +68 -4
  206. package/src/stages/archive.js +53 -12
  207. package/src/stages/brainstorm-auto.js +7 -0
  208. package/src/stages/brainstorm.js +11 -0
  209. package/src/stages/doctor.js +157 -1
  210. package/src/stages/execute.js +33 -6
  211. package/src/stages/plan-postcheck.js +79 -18
  212. package/src/stages/plan.js +4 -0
  213. package/src/stages/quick.js +6 -1
  214. package/src/stages/verify.js +31 -14
  215. package/src/sync.js +94 -2
  216. package/src/task-review.js +2 -2
  217. package/src/taskcard-placeholders.js +21 -0
  218. package/src/taskcard.js +10 -1
  219. package/src/verify-postcheck.js +315 -13
  220. package/src/verify-probes.js +46 -4
  221. package/src/worktree-apply.js +184 -7
  222. package/src/worktree-deps.js +69 -0
  223. package/src/worktree.js +201 -58
  224. package/templates/prompts/taskcard-rules.md +1 -0
@@ -0,0 +1,1344 @@
1
+ /**
2
+ * Plan Postcheck 模块
3
+ *
4
+ * 从 run.js 和 plan.js 拆出的确定性校验逻辑:
5
+ * - 拓扑排序(topoSortWaves)
6
+ * - 蓝图一致性校验(validateBlueprintConsistency)
7
+ * - plan 产物校验(validatePlanArtifacts)
8
+ * - 执行入口(executePlanPostcheck)
9
+ *
10
+ * 这些都是 noAI 步骤,不需要 LLM 参与。
11
+ */
12
+ import { existsSync, readFileSync as _readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs'
13
+ // 归一化行尾为 LF:Windows 下 python/编辑器文本模式写 .md 会产生 CRLF,致本模块多处
14
+ // frontmatter/字段正则(`^---\n`、`allowed_paths:\s*\n…`、`^goal:` 等)失配,报「缺 frontmatter
15
+ // /缺字段」假错误(见缺陷 windows-python-crlf-taskcard)。读取时统一转 LF,一处覆盖全部正则。
16
+ const readFileSync = (filePath, encoding) => _readFileSync(filePath, encoding).replace(/\r\n/g, '\n')
17
+ import { join as pJoin, basename } from 'path'
18
+ import { tmpdir } from 'os'
19
+ import jsYaml from 'js-yaml'
20
+ import { parseFileChangeList, pathMatches } from '../change-list.js'
21
+ import { getRule } from '../stage-contract-spec.js'
22
+ import { validateScriptCommands } from './cmd-existence.js'
23
+
24
+ // ═══════════════════════════════════════════════════════════════
25
+ // 解析工具(从 plan.js 迁移)
26
+ // ═══════════════════════════════════════════════════════════════
27
+
28
+ /**
29
+ * 从 task-NN.md frontmatter 解析 depends_on 字段
30
+ * @param {string} content - task 文件内容
31
+ * @returns {string[]}
32
+ */
33
+ function parseDependsOn(content) {
34
+ content = content.replace(/\r\n/g, '\n') // 外部调用方(worktree-apply 等)原生 readFileSync 喂 CRLF 时 ^---\n 锚点失配
35
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
36
+ if (!fmMatch) return []
37
+ const fm = fmMatch[1]
38
+ const inlineMatch = fm.match(/depends_on:\s*\[([^\]]*)\]/)
39
+ if (inlineMatch) {
40
+ return inlineMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)
41
+ }
42
+ // 块列表:列表项允许顶格或缩进([ \t]*,坑6——原 \s* 贪婪吃掉换行与前导空白,
43
+ // 顶格 `depends_on:\n- task-01` 标准 YAML 写法永远失配,静默判「无依赖」)
44
+ const blockMatch = fm.match(/depends_on:[ \t]*\n((?:[ \t]*-[ \t]+.+\n?)+)/)
45
+ if (blockMatch) {
46
+ return blockMatch[1].match(/[ \t]*-[ \t]+(.+)/g)?.map(s => s.replace(/^[ \t]*-[ \t]+/, '').trim().replace(/['"]/g, '')) || []
47
+ }
48
+ return []
49
+ }
50
+
51
+ /**
52
+ * 解析 task-NN.md 的 task id(从 frontmatter 或文件名)
53
+ * @param {string} content - task 文件内容
54
+ * @param {string} filename - 文件名
55
+ * @returns {string|null}
56
+ */
57
+ function parseTaskId(content, filename) {
58
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
59
+ if (fmMatch) {
60
+ const idMatch = fmMatch[1].match(/^id:\s*(.+)/m)
61
+ if (idMatch) return idMatch[1].trim()
62
+ }
63
+ const fileMatch = filename.match(/(task-\d+)/i)
64
+ return fileMatch ? fileMatch[1] : null
65
+ }
66
+
67
+ /**
68
+ * 解析 task-NN.md 的 allowed_paths
69
+ * @param {string} content
70
+ * @returns {string[]}
71
+ */
72
+ export function parseAllowedPaths(content) {
73
+ content = content.replace(/\r\n/g, '\n') // 同 parseDependsOn:入口归一,保护喂原始 CRLF 内容的外部调用方
74
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
75
+ if (!fmMatch) return []
76
+ const fm = fmMatch[1]
77
+ const inlineMatch = fm.match(/allowed_paths:\s*\[([^\]]*)\]/)
78
+ if (inlineMatch) {
79
+ return inlineMatch[1].split(',').map(s => s.trim().replace(/['"`]/g, '')).filter(Boolean)
80
+ }
81
+ // 块列表:列表项允许顶格或缩进(坑6①——原 `\s*\n` 的 \s 贪婪匹配换行+前导空白,
82
+ // 标准 YAML 块列表 `allowed_paths:\n- src/a.js`(顶格)永远失配,静默判「缺 allowed_paths」;
83
+ // 缩进式 \s+- 恰可命中是巧合幸存。改 [ \t]*(不吃换行)+ 行内 [ \t]*-[ \t]+ 顶格缩进通吃)
84
+ const blockMatch = fm.match(/allowed_paths:[ \t]*\n((?:[ \t]*-[ \t]+.+\n?)+)/)
85
+ if (blockMatch) {
86
+ // 剥首尾成对反引号(坑7 家族:`src/a.js` 整项以反引号开头时 js-yaml/正则路径带 ` 匹配失败报未覆盖;
87
+ // 与 change-list.js 的 cell 归一化同语义——剥两端包裹引号,不动路径内部字符)
88
+ return blockMatch[1].match(/[ \t]*-[ \t]+(.+)/g)?.map(s => {
89
+ const raw = s.replace(/^[ \t]*-[ \t]+/, '').trim()
90
+ return raw.replace(/^`([^`]*)`$/, '$1').replace(/^['"]|['"]$/g, '').trim()
91
+ }).filter(Boolean) || []
92
+ }
93
+ return []
94
+ }
95
+
96
+ /**
97
+ * 从 task-NN.md frontmatter 解析标量字段(repo / base_commit / head_commit)。
98
+ * 复用 parseTaskContracts 的 frontmatter 提取 + js-yaml load 模式(标量可选字段,正则对引号/空值脆弱)。
99
+ *
100
+ * design §7.2 跨仓 task 卡 frontmatter 协议:
101
+ * repo: sillyspec # 可选,缺省='main'(调用方视 null 为 main)
102
+ * base_commit: <sha> # 可选,CLI 锡点写入(派发前落盘 base)
103
+ * head_commit: <sha> # 可选,CLI 锡点写入(回收 review 前落盘 head)
104
+ *
105
+ * 缺省 / 空值 / 无 frontmatter → null(向后兼容旧 task 卡)。
106
+ * @param {string} content - task 文件内容
107
+ * @param {string} field - frontmatter 字段名
108
+ * @returns {string|null}
109
+ */
110
+ function parseFrontmatterScalar(content, field) {
111
+ content = content.replace(/\r\n/g, '\n') // 同 parseAllowedPaths:入口归一 CRLF
112
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
113
+ if (!fmMatch) return null
114
+ let fm
115
+ try {
116
+ fm = jsYaml.load(fmMatch[1]) || {}
117
+ } catch {
118
+ return null
119
+ }
120
+ const v = fm[field]
121
+ if (v == null) return null
122
+ const s = String(v).trim()
123
+ return s === '' ? null : s
124
+ }
125
+
126
+ /**
127
+ * 解析 task-NN.md frontmatter 的 repo 字段(跨仓 task 声明)。
128
+ * 缺省=null(=main,调用方按主仓处理)。design §7.2 / D-001。
129
+ * @param {string} content
130
+ * @returns {string|null}
131
+ */
132
+ export function parseRepo(content) {
133
+ return parseFrontmatterScalar(content, 'repo')
134
+ }
135
+
136
+ /**
137
+ * 解析 task-NN.md frontmatter 的 base_commit 字段(CLI 派发跨仓 task 前落盘的 base 锡点)。
138
+ * 缺省=null(未锡点 / 单仓 task)。design §7.2 / D-010。
139
+ * @param {string} content
140
+ * @returns {string|null}
141
+ */
142
+ export function parseBaseCommit(content) {
143
+ return parseFrontmatterScalar(content, 'base_commit')
144
+ }
145
+
146
+ /**
147
+ * 解析 task-NN.md frontmatter 的 head_commit 字段(CLI 回收 review 前落盘的 head 锡点)。
148
+ * 缺省=null(未锡点 / 单仓 task)。design §7.2 / D-010。
149
+ * @param {string} content
150
+ * @returns {string|null}
151
+ */
152
+ export function parseHeadCommit(content) {
153
+ return parseFrontmatterScalar(content, 'head_commit')
154
+ }
155
+
156
+ /**
157
+ * 从 local.yaml 文本解析 repos: 段(跨仓 workspace 注册表)。
158
+ * 与 parseLocalYamlModules 同风格(轻量行扫描,不引 yaml 依赖),结构 Map<repoKey, absolutePath>。
159
+ *
160
+ * design §7.3 local.yaml repos schema:
161
+ * repos:
162
+ * sillyspec: C:/Users/qinyi/IdeaProjects/sillyspec
163
+ * # main 不用注册(隐式 = cwd / specRoot 父目录)
164
+ *
165
+ * 无 repos 段 / 空段 / 入参空 → 空 Map(单仓 change 不读,向后兼容)。
166
+ *
167
+ * **职责边界**:本函数只 parse local.yaml 文本,不读 local.yaml 文件(文件读取入口归
168
+ * execute 启动 / task-09)。本函数不校验 declaredRepos ⊆ registry(D-007 fail-closed
169
+ * 校验在 MultiRepoContext 构造时做)。
170
+ * @param {string} yamlText
171
+ * @returns {Map<string, string>} repoKey → absolutePath
172
+ */
173
+ export function parseRepoRegistry(yamlText) {
174
+ const reg = new Map()
175
+ if (!yamlText) return reg
176
+ // CRLF 归一(坑 register-repo-crlf-idempotent-loop,2026-08-23 实证:Windows 下 agent Write
177
+ // 工具/编辑器写出的 local.yaml 带 \r——条目正则 `(.*)$` 的 `.` 不匹配 \r、`$` 又要求真串尾,
178
+ // 行尾残留 \r 时整条失配 → 空 Map → MultiRepoContext fail-closed 报「未注册」→ register-repo
179
+ // 幂等跳过不落盘 → 死循环。同文件 parseAllowedPaths/parseDependsOn/parseBaseCommit 均有此
180
+ // 归一,本函数是唯一缺口)
181
+ const lines = String(yamlText).replace(/\r\n?/g, '\n').split('\n')
182
+ let startIdx = -1
183
+ for (let i = 0; i < lines.length; i++) {
184
+ if (/^repos:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
185
+ }
186
+ if (startIdx === -1) return reg
187
+ for (let i = startIdx + 1; i < lines.length; i++) {
188
+ const line = lines[i]
189
+ // 遇到新的顶层 key(行首非空格且非注释)→ repos 段结束
190
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && !line.startsWith('#') && line.trim() !== '') break
191
+ // 条目格式: <key>: <value>(key 限 [A-Za-z0-9_.\-],与 parseLocalYamlModules 对齐)
192
+ const entry = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/)
193
+ if (!entry) continue
194
+ const value = (entry[2] || '').trim()
195
+ if (value === '' || value.startsWith('#')) continue
196
+ // 去可选行内注释(`value # comment`)—— 注意路径不含 ` #`,注释前必带空格
197
+ const cleaned = value.replace(/\s+#.*$/, '').trim()
198
+ if (cleaned === '') continue
199
+ // 去首尾可选引号(YAML 字符串引用),保留路径内反斜杠原样
200
+ const path = cleaned.replace(/^['"]|['"]$/g, '')
201
+ if (path === '') continue
202
+ reg.set(entry[1], path)
203
+ }
204
+ return reg
205
+ }
206
+
207
+ /**
208
+ * 检查 task-NN.md 是否包含验收标准章节
209
+ * @param {string} content
210
+ * @returns {boolean}
211
+ */
212
+ function hasAcceptanceCriteria(content) {
213
+ // 新模板把 goal/implementation/acceptance/verify/constraints 放在 frontmatter
214
+ // (acceptance: 作为 fm 字段),老格式或人工写的卡片可能用 body 章节。
215
+ // 两种都认,避免「模板格式」与「postcheck 判定」不一致导致卡片过不了校验。
216
+ if (/^acceptance:/m.test(content)) return true
217
+ return /##\s*验收标准/.test(content) || /##\s*Acceptance/.test(content)
218
+ }
219
+
220
+ /**
221
+ * 检查 task-NN.md 是否包含 TDD/验证步骤
222
+ * @param {string} content
223
+ * @returns {boolean}
224
+ */
225
+ function hasTddOrVerify(content) {
226
+ // 同 hasAcceptanceCriteria:新模板用 frontmatter 的 verify: 字段,老格式用 body 章节。
227
+ if (/^verify:/m.test(content)) return true
228
+ return /##\s*TDD/.test(content) || /##\s*验证/.test(content) || /##\s*Verify/.test(content)
229
+ }
230
+
231
+ /**
232
+ * 解析 task-NN.md 的跨任务契约字段 provides / expects_from
233
+ * 用 js-yaml 解析 frontmatter(嵌套结构,正则不可靠)。
234
+ * provides/expects_from 为可选字段;缺失或解析失败时返回空(不阻断)。
235
+ * @param {string} content - task 文件内容
236
+ * @returns {{ provides: Array<{contract, fields}>, expectsFrom: Record<string, Array<{contract, needs}>> }}
237
+ */
238
+ export function parseTaskContracts(content) {
239
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
240
+ if (!fmMatch) return { provides: [], expectsFrom: {} }
241
+ let fm
242
+ try {
243
+ fm = jsYaml.load(fmMatch[1]) || {}
244
+ } catch {
245
+ // frontmatter 非合法 YAML(老格式/手写不规范)—— 当作无契约字段,不阻断
246
+ return { provides: [], expectsFrom: {} }
247
+ }
248
+
249
+ const provides = Array.isArray(fm.provides)
250
+ ? fm.provides.map(p => ({
251
+ contract: String(p?.contract || ''),
252
+ fields: Array.isArray(p?.fields) ? p.fields.map(String) : [],
253
+ })).filter(p => p.contract)
254
+ : []
255
+
256
+ const expectsFrom = {}
257
+ if (fm.expects_from && typeof fm.expects_from === 'object' && !Array.isArray(fm.expects_from)) {
258
+ for (const [providerTask, contracts] of Object.entries(fm.expects_from)) {
259
+ if (!Array.isArray(contracts)) continue
260
+ expectsFrom[providerTask] = contracts
261
+ .map(c => ({
262
+ contract: String(c?.contract || ''),
263
+ needs: Array.isArray(c?.needs) ? c.needs.map(String) : [],
264
+ }))
265
+ .filter(c => c.contract)
266
+ }
267
+ }
268
+
269
+ return { provides, expectsFrom }
270
+ }
271
+
272
+ // ═══════════════════════════════════════════════════════════════
273
+ // 核心逻辑
274
+ // ═══════════════════════════════════════════════════════════════
275
+
276
+ /**
277
+ * 拓扑排序:根据 depends_on 计算波次
278
+ * @param {Map<string, string[]>} depMap - taskId → depends_on list
279
+ * @returns {{ waves: string[][], error: string|null }}
280
+ */
281
+ export function topoSortWaves(depMap) {
282
+ const tasks = [...depMap.keys()]
283
+ const waves = []
284
+ const assigned = new Set()
285
+ const visited = new Set()
286
+
287
+ // 先做循环依赖检测(DFS)
288
+ function hasCycle(task, path) {
289
+ if (path.has(task)) return true
290
+ if (visited.has(task)) return false
291
+ path.add(task)
292
+ const deps = depMap.get(task) || []
293
+ for (const dep of deps) {
294
+ if (!depMap.has(dep)) continue // 依赖不存在(可能是外部引用),跳过
295
+ if (hasCycle(dep, path)) return true
296
+ }
297
+ path.delete(task)
298
+ visited.add(task)
299
+ return false
300
+ }
301
+ for (const task of tasks) {
302
+ if (hasCycle(task, new Set())) {
303
+ return { waves: [], error: `检测到循环依赖,涉及 task: ${task}` }
304
+ }
305
+ }
306
+
307
+ // 逐层分配 Wave
308
+ while (assigned.size < tasks.length) {
309
+ const currentWave = []
310
+ for (const task of tasks) {
311
+ if (assigned.has(task)) continue
312
+ const deps = (depMap.get(task) || []).filter(d => depMap.has(d))
313
+ if (deps.every(d => assigned.has(d))) {
314
+ currentWave.push(task)
315
+ }
316
+ }
317
+ if (currentWave.length === 0) {
318
+ const remaining = tasks.filter(t => !assigned.has(t))
319
+ return { waves: [], error: `无法解析依赖关系,剩余 task: ${remaining.join(', ')}` }
320
+ }
321
+ for (const task of currentWave) {
322
+ assigned.add(task)
323
+ }
324
+ waves.push(currentWave)
325
+ }
326
+
327
+ return { waves, error: null }
328
+ }
329
+
330
+ /**
331
+ * 解析 plan.md 显式 Wave 分组 → Map<taskId, waveIndex>。
332
+ *
333
+ * Wave 口径与 execute.js parseWavesFromPlan 一致(execute 的真实并行单元,非 topoSort 建议值):
334
+ * - `## Wave N` 标题下的 task-XX checkbox 归入 Wave N;
335
+ * - 非 Wave 标题行(## 自检 等)退出当前 Wave 段,避免其 checkbox 混入。
336
+ * 返回 null = plan.md 不存在或无显式 Wave 标题 → execute 会把所有 task 放进单个隐式 Wave
337
+ * 全并行(execute.js:402-408),调用方据此判「同文件即冲突」。
338
+ *
339
+ * @param {string} planPath - plan.md 绝对路径
340
+ * @returns {Map<string, number>|null} taskId → waveIndex;null = 无显式 Wave
341
+ */
342
+ function parseTaskWavesFromPlan(planPath) {
343
+ if (!existsSync(planPath)) return null
344
+ const content = readFileSync(planPath, 'utf8')
345
+ if (!/^#+\s*Wave\s+\d+/im.test(content)) return null
346
+ const map = new Map()
347
+ let currentWave = null
348
+ for (const line of content.split('\n')) {
349
+ const wm = line.match(/^#+\s*Wave\s+(\d+)/i)
350
+ if (wm) { currentWave = parseInt(wm[1], 10); continue }
351
+ if (/^#+\s/.test(line)) { currentWave = null; continue } // 非 Wave 标题退出当前 Wave 段
352
+ // 2026-08-20-task-truth-unify:Wave 段下为纯 ID 引用行(- task-XX);旧 checkbox 行同收
353
+ // (旧变更读侧兼容——新格式由 validatePlanForExecute 拦旧形态,此处不重复把关)
354
+ const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?(task-\d+)\s*[::\s]*$/i)
355
+ || line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
356
+ // id 归一补零(与卡片/注册表 task-01 口径一致,坑 wave-ref-unpadded):plan.md 写
357
+ // `- task-1` 时裸用捕获原文做 key,永远匹配不到卡片 id task-01 → 被判「未列入任何
358
+ // Wave」,同 Wave 共享文件检测静默跳过。execute.js parseWavesFromPlan 已补零,对齐。
359
+ if (tm && currentWave !== null) map.set(String(tm[1]).replace(/^task-(\d+)$/i, (_, n) => `task-${n.padStart(2, '0')}`), currentWave)
360
+ }
361
+ return map
362
+ }
363
+
364
+ /**
365
+ * 本地一致性校验器
366
+ * @param {string} changeDir - 变更目录
367
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
368
+ */
369
+ export function validateBlueprintConsistency(changeDir) {
370
+ const errors = []
371
+ const warnings = []
372
+
373
+ const tasksDir = pJoin(changeDir, 'tasks')
374
+ if (!existsSync(tasksDir)) {
375
+ return { ok: false, errors: ['tasks/ 目录不存在'], warnings }
376
+ }
377
+
378
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
379
+ if (taskFiles.length === 0) {
380
+ return { ok: false, errors: ['tasks/ 目录下没有 task-NN.md 文件'], warnings }
381
+ }
382
+
383
+ // task 卡片基础字段文案从 manifest 同源(plan.task-card-structure);${id} 由下方 `${taskId} (${file})` 替换。
384
+ const bsRule = getRule('plan.task-card-structure')
385
+ const taskInfo = new Map()
386
+ // pathOwners 按 (repo, path) 二元组聚合(design §5.3 约束③ / D-008):键 = `${repo}|${path}`,
387
+ // repo 缺省='main'(parseRepo 返 null → main)。跨仓 task 与主仓 task 同名物理路径分属不同 repo
388
+ // → 二元组键不同 → 不误判同 Wave 冲突(如主仓 src/task-review.js vs sillyspec src/task-review.js)。
389
+ // ⚠️ 键分隔符 `|` 假设 repo 名与 path 均不含 `|`(repo 名约束 [A-Za-z0-9_.\-]、path 含 / \,极低概率含 |)。
390
+ // value 结构 { repo, path, owners }:冲突文案对外仍显示纯 path(用户体验),内部按二元组判冲突。
391
+ const pathOwners = new Map()
392
+
393
+ for (const file of taskFiles) {
394
+ const filePath = pJoin(tasksDir, file)
395
+ const content = readFileSync(filePath, 'utf8')
396
+ const taskId = parseTaskId(content, file)
397
+ if (!taskId) {
398
+ errors.push(`${file}: 无法解析 task id`)
399
+ continue
400
+ }
401
+
402
+ const dependsOn = parseDependsOn(content)
403
+ const allowedPaths = parseAllowedPaths(content)
404
+ const hasAcceptance = hasAcceptanceCriteria(content)
405
+ const hasTdd = hasTddOrVerify(content)
406
+ // repo 缺省='main'(parseRepo 返 null → 单仓 task,与既有行为零回归)
407
+ const repo = parseRepo(content) || 'main'
408
+
409
+ taskInfo.set(taskId, { dependsOn, allowedPaths, hasAcceptance, hasTdd, file })
410
+
411
+ if (allowedPaths.length === 0) {
412
+ errors.push(bsRule.data.messageAllowedPaths.replaceAll('${id}', `${taskId} (${file})`))
413
+ }
414
+ if (!hasAcceptance) {
415
+ errors.push(bsRule.data.messageAcceptance.replaceAll('${id}', `${taskId} (${file})`))
416
+ }
417
+ if (!hasTdd) {
418
+ warnings.push(bsRule.data.messageTdd.replaceAll('${id}', `${taskId} (${file})`))
419
+ }
420
+
421
+ for (const p of allowedPaths) {
422
+ const key = `${repo}|${p}`
423
+ if (!pathOwners.has(key)) pathOwners.set(key, { repo, path: p, owners: [] })
424
+ pathOwners.get(key).owners.push(taskId)
425
+ }
426
+ }
427
+
428
+ // 路径冲突(Wave 感知):同 repo 同 Wave 内 >1 task 共享 allowed_path → execute 强制并行(execute.js:603)
429
+ // 子代理会互相覆盖该文件 → error。跨 Wave 同文件 → 串行执行安全 → warning。
430
+ // 按 (repo, path) 二元组判冲突(约束③):跨仓 task 与主仓 task 同名路径不误判(不同 repo → 不同键)。
431
+ // Wave 口径 = plan.md 显式 `## Wave N`(parseTaskWavesFromPlan,与 execute 同源,非 topoSort 建议值);
432
+ // plan.md 无显式 Wave → execute 全并行(隐式单 Wave,execute.js:402-408)→ 同 repo 同文件即冲突。
433
+ const waveOfTask = parseTaskWavesFromPlan(pJoin(changeDir, 'plan.md'))
434
+ for (const [, { repo, path: p, owners }] of pathOwners) {
435
+ if (owners.length < 2) continue
436
+ if (waveOfTask === null) {
437
+ errors.push(
438
+ `路径 ${p} 被 ${owners.length} 个 task 修改: ${owners.join(', ')} — plan.md 无显式 Wave 划分,` +
439
+ `execute 会把它们放进同一隐式 Wave 全并行(execute.js:402-408),子代理互相覆盖。` +
440
+ `解法:拆到不同 Wave(串行),或合并为单个 task。`
441
+ )
442
+ continue
443
+ }
444
+ // 显式 Wave:按 wave 分组 owners,找同 Wave 冲突
445
+ const byWave = new Map()
446
+ for (const t of owners) {
447
+ const w = waveOfTask.get(t)
448
+ if (w === undefined) continue // 未列入任何 Wave → execute 不执行,不参与冲突判定
449
+ if (!byWave.has(w)) byWave.set(w, [])
450
+ byWave.get(w).push(t)
451
+ }
452
+ for (const [w, ts] of byWave) {
453
+ if (ts.length > 1) {
454
+ errors.push(
455
+ `路径 ${p} 被 Wave ${w} 内 ${ts.length} 个 task 修改: ${ts.join(', ')} — 同 Wave 任务 execute 强制并行(execute.js:603),` +
456
+ `子代理会互相覆盖该文件。解法:把它们拆到不同 Wave(串行)。` +
457
+ `(注:quick 阶段对同文件并发只提示不阻断——那是轻量流程无并行子代理、最坏后果 git 可分离;` +
458
+ `此处硬拦是因并行覆盖不可自动恢复,两阶段宽严不同是设计使然)`
459
+ )
460
+ }
461
+ }
462
+ // 跨 Wave 共享:串行安全,仅提示
463
+ if (byWave.size > 1) {
464
+ warnings.push(`路径 ${p} 跨 Wave 被修改: ${owners.join(', ')}(不同 Wave 串行执行,安全;确认是否有意共享)`)
465
+ }
466
+ }
467
+
468
+ // 任务声明 ↔ tasks/ 卡片双向对账(债单 D-2b;2026-08-20-task-truth-unify:声明源迁 tasks.md 注册表):
469
+ // 注册表列 17 任务只出 12 卡(尾部文档同步类任务漏卡)时,无卡任务不进 Wave、
470
+ // 不受 execute 审计 → 零失败信号漏做。口径与 execute.js parseTaskRegistry 一致
471
+ // (`- [ ] task-NN` checkbox 行;file 命名 task-NN.md + 卡内 id task-NN 均取,缺一即漏卡)。
472
+ const planMdPath = pJoin(changeDir, 'plan.md')
473
+ const declaredIds = new Set()
474
+ const tasksMdPath = pJoin(changeDir, 'tasks.md')
475
+ if (existsSync(tasksMdPath)) {
476
+ for (const line of readFileSync(tasksMdPath, 'utf8').split('\n')) {
477
+ const m = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
478
+ if (m) declaredIds.add(m[1].toLowerCase())
479
+ }
480
+ }
481
+ if (declaredIds.size === 0 && existsSync(planMdPath)) {
482
+ // 兼容读侧:tasks.md 缺失/无声明时回退解析 plan.md 旧 checkbox 声明(旧归档变更)
483
+ for (const line of readFileSync(planMdPath, 'utf8').split('\n')) {
484
+ const m = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
485
+ if (m) declaredIds.add(m[1].toLowerCase())
486
+ }
487
+ }
488
+ if (declaredIds.size > 0) {
489
+ const tprRule = getRule('plan.task-plan-reconciliation')
490
+ const cardIds = new Set([...taskInfo.keys()].map(id => id.toLowerCase()))
491
+ const missing = [...declaredIds].filter(id => !cardIds.has(id))
492
+ if (missing.length > 0) {
493
+ errors.push(
494
+ tprRule.data.messageMissingCards
495
+ .replaceAll('${declared}', declaredIds.size)
496
+ .replaceAll('${missing}', missing.join(', '))
497
+ )
498
+ }
499
+ const orphans = [...cardIds].filter(id => !declaredIds.has(id))
500
+ if (orphans.length > 0) {
501
+ errors.push(
502
+ tprRule.data.messageOrphanCards.replaceAll('${orphans}', orphans.join(', '))
503
+ )
504
+ }
505
+ }
506
+ // declaredIds.size === 0:注册表无 task 声明(极端格式)→ 跳过对账,
507
+ // 由 validatePlanArtifacts/validatePlanForExecute 把关,不在此误伤。
508
+
509
+ // 拓扑排序 + 循环依赖
510
+ const depMap = new Map()
511
+ for (const [taskId, info] of taskInfo) {
512
+ depMap.set(taskId, info.dependsOn)
513
+ }
514
+ const { error: topoError } = topoSortWaves(depMap)
515
+ if (topoError) {
516
+ errors.push(topoError)
517
+ }
518
+
519
+ return { ok: errors.length === 0, errors, warnings }
520
+ }
521
+
522
+ /**
523
+ * 跨任务契约校验器:对账 consumer.expects_from ↔ provider.provides
524
+ *
525
+ * 修复场景:consumer task 声明期望 provider 产出某 DTO 的某字段,
526
+ * 但 provider 的 provides 未承诺(或字段缺失)→ plan 阶段阻断,
527
+ * 避免到 execute/verify 才暴露(典型表现:前端 fallback 错误字段 → 403/500)。
528
+ *
529
+ * provides / expects_from 均为可选字段:未声明时不校验(向后兼容)。
530
+ * @param {string} changeDir - 变更目录
531
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
532
+ */
533
+ export function validateCrossTaskContracts(changeDir) {
534
+ const errors = []
535
+ const warnings = []
536
+
537
+ const tasksDir = pJoin(changeDir, 'tasks')
538
+ if (!existsSync(tasksDir)) {
539
+ return { ok: true, errors, warnings }
540
+ }
541
+
542
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
543
+ if (taskFiles.length === 0) {
544
+ return { ok: true, errors, warnings }
545
+ }
546
+
547
+ // 第一遍:收集每个 task 的 provides(taskId → Map(contract → Set(fields)))
548
+ const providesByTask = new Map()
549
+ for (const file of taskFiles) {
550
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
551
+ const taskId = parseTaskId(content, file)
552
+ if (!taskId) continue
553
+ const { provides } = parseTaskContracts(content)
554
+ const contractMap = new Map()
555
+ for (const p of provides) {
556
+ contractMap.set(p.contract, new Set(p.fields))
557
+ }
558
+ providesByTask.set(taskId, contractMap)
559
+ }
560
+
561
+ // 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖。
562
+ // 3 种断裂文案(unknown-provider / undeclared / missing-fields)从 manifest 同源
563
+ // (plan.cross-task-contract.data),${consumer}/${provider}/${contract}/${needs}/${available} replaceAll。
564
+ const ctRule = getRule('plan.cross-task-contract')
565
+ const renderCtMsg = (tmpl, consumer, provider, contract, needs, available) => tmpl
566
+ .replaceAll('${consumer}', consumer)
567
+ .replaceAll('${provider}', provider)
568
+ .replaceAll('${contract}', contract)
569
+ .replaceAll('${needs}', needs)
570
+ .replaceAll('${available}', available)
571
+ for (const file of taskFiles) {
572
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
573
+ const consumerId = parseTaskId(content, file)
574
+ if (!consumerId) continue
575
+ const { expectsFrom } = parseTaskContracts(content)
576
+
577
+ for (const [providerTask, contracts] of Object.entries(expectsFrom)) {
578
+ if (!providesByTask.has(providerTask)) {
579
+ for (const c of contracts) {
580
+ errors.push(renderCtMsg(ctRule.data.messageUnknownProvider, consumerId, providerTask, c.contract, c.needs.join(', '), ''))
581
+ }
582
+ continue
583
+ }
584
+
585
+ const providerContracts = providesByTask.get(providerTask)
586
+ for (const c of contracts) {
587
+ const providerFields = providerContracts.get(c.contract)
588
+ if (providerFields === undefined) {
589
+ errors.push(renderCtMsg(ctRule.data.messageUndeclaredContract, consumerId, providerTask, c.contract, c.needs.join(', '), ''))
590
+ continue
591
+ }
592
+ const missing = c.needs.filter(f => !providerFields.has(f))
593
+ if (missing.length > 0) {
594
+ errors.push(renderCtMsg(ctRule.data.messageMissingFields, consumerId, providerTask, c.contract, missing.join(', '), [...providerFields].join(', ')))
595
+ }
596
+ }
597
+ }
598
+ }
599
+
600
+ return { ok: errors.length === 0, errors, warnings }
601
+ }
602
+
603
+ /**
604
+ * 从 local.yaml 文本解析 modules 块(monorepo 子包定位)。
605
+ *
606
+ * 与 verify-postcheck.extractModules 同风格(轻量行扫描,不引 yaml 依赖),不复用是为避免
607
+ * plan-postcheck → verify-postcheck → contract-matrix → plan-postcheck 的循环导入。
608
+ * 只取 path 字段(test 字段此处用不到),结构 { name: { path } }。
609
+ *
610
+ * modules:
611
+ * backend: { path: "backend/", test: "cd backend && uv run pytest" }
612
+ *
613
+ * @param {string} yamlText
614
+ * @returns {Record<string, {path:string}>|null} 无 modules 块或无有效条目 → null
615
+ */
616
+ export function parseLocalYamlModules(yamlText) {
617
+ if (!yamlText) return null
618
+ const lines = yamlText.split('\n')
619
+ let startIdx = -1
620
+ for (let i = 0; i < lines.length; i++) {
621
+ if (/^modules:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
622
+ }
623
+ if (startIdx === -1) return null
624
+ const modules = {}
625
+ for (let i = startIdx + 1; i < lines.length; i++) {
626
+ const line = lines[i]
627
+ const entry = line.match(/^([ \t]+)([A-Za-z0-9_.\-]+):\s*(.*)$/)
628
+ if (!entry) {
629
+ // 遇到新的顶层 key(行首非空格且非注释)→ modules 块结束
630
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
631
+ continue
632
+ }
633
+ // 条目缩进放宽为「任意非零空白」(坑 modules-indent-hardcoded):此前硬编码恰 2 空格,
634
+ // 4 空格/Tab 缩进的条目不匹配且不触发块结束 → 被静默跳过 → modules 丢失 →
635
+ // validateScriptCommands 退化为只查根 package.json,monorepo 子包命令被误报不存在。
636
+ const rest = entry[3] || ''
637
+ // path 值:带引号或不带引号(flow mapping),取第一个匹配
638
+ const pathMatch = rest.match(/path:\s*"([^"]+)"/) || rest.match(/path:\s*([^,\s}]+)/)
639
+ if (pathMatch) modules[entry[2]] = { path: pathMatch[1] }
640
+ }
641
+ return Object.keys(modules).length > 0 ? modules : null
642
+ }
643
+
644
+ /**
645
+ * TaskCard 命令存在性校验器(调共享 validateScriptCommands,硬阻断)。
646
+ *
647
+ * 修复场景(design D-04 / 问题 3):TaskCard verify/implementation 写 `pnpm gen:types`
648
+ * 但根 package.json 无此 script(实际在 monorepo 子包 packages 目录下),plan 阶段零校验
649
+ * → execute 子代理跑死命令。scan-postcheck 的命令校验只看 local.yaml 且维持 warning;
650
+ * 本函数对 TaskCard 升 error(同 helper、严重度由调用方定)。
651
+ *
652
+ * modules 从 local.yaml 提取(monorepo 子包感知):无 modules 块时仅查根 package.json
653
+ * (与 scan-postcheck 历史行为一致);有 modules 块时多候选子包 package.json 任一命中即通过。
654
+ *
655
+ * @param {string} changeDir - 变更目录
656
+ * @param {string} projectRoot - 项目根目录(package.json 查找基准)
657
+ * @param {object|null} modules - local.yaml modules 块({ name: { path } }),可选
658
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
659
+ */
660
+ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
661
+ const errors = []
662
+ const warnings = []
663
+
664
+ const tasksDir = pJoin(changeDir, 'tasks')
665
+ if (!existsSync(tasksDir)) {
666
+ return { ok: true, errors, warnings }
667
+ }
668
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
669
+ if (taskFiles.length === 0) {
670
+ return { ok: true, errors, warnings }
671
+ }
672
+
673
+ for (const file of taskFiles) {
674
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
675
+ const taskId = parseTaskId(content, file) || file
676
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
677
+ if (!fmMatch) continue // feasibility 已报 frontmatter 缺失,不重复
678
+ let fm
679
+ try {
680
+ fm = jsYaml.load(fmMatch[1]) || {}
681
+ } catch {
682
+ continue // 非法 YAML 由 feasibility 处理,不重复
683
+ }
684
+
685
+ // 合并 verify + implementation 文本(命令可能出现在任一字段)。
686
+ // 字段两形态都收(坑 taskcard-block-list-noop):规范格式(taskcard.js 骨架 / plan 模板)的
687
+ // verify/implementation 是 YAML 块列表 → js-yaml 解析为数组;此前只收 string,规范卡片
688
+ // 恒落空 → 「命令存在性校验」(hard-error)对合规 TaskCard 整体 no-op,只有违规写成
689
+ // 纯标量的卡片才被校验。数组按行 join 回文本,与标量同管道。
690
+ const toCmdText = (v) => Array.isArray(v) ? v.map(String).join('\n') : (typeof v === 'string' ? v : '')
691
+ const verifyText = toCmdText(fm.verify)
692
+ const implText = toCmdText(fm.implementation)
693
+ const text = `${verifyText}\n${implText}`
694
+ if (!text.trim()) continue
695
+
696
+ const { invalid } = validateScriptCommands(text, { projectRoot, modules })
697
+ for (const inv of invalid) {
698
+ errors.push(`${taskId}: ${inv.cmd} 命令不存在(${inv.reason})`)
699
+ }
700
+ }
701
+
702
+ return { ok: errors.length === 0, errors, warnings }
703
+ }
704
+
705
+ // 路径容差匹配(normalizePath / globMatch / pathMatches)复用自 change-list.js,
706
+ // design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
707
+
708
+ /**
709
+ * design §6 按仓分段段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
710
+ * 段头为 h2,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀与尾随空白/冒号。
711
+ * 不命中(非仓变更段头)→ null。
712
+ */
713
+ const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更\s*[::]?\s*$/
714
+
715
+ /**
716
+ * design §6 文件清单章节标题(与 change-list.js FILE_LIST_SECTION_RE 同源,避免 import 私有常量)。
717
+ * 容忍可选编号前缀(`## 6. 文件变更清单`),与 change-list 解析口径一致。
718
+ */
719
+ const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变更文件清单|文件清单|File Changes|Files to Change)/im
720
+
721
+ /**
722
+ * 按 repo 解析 design §6 文件清单(约束③ / D-014)。
723
+ *
724
+ * 支持 design §6 按仓分段:`## <repo> 仓变更` 段头下的路径归该 repo;无段头时整章节归 'main'
725
+ * (向后兼容单仓 design,与原 parseFileChangeList 行为等价)。段内路径解析复用 parseFileChangeList
726
+ * (表格列定位 / exclude 子段 / 占位符过滤 / CRLF 容错全部继承,零漂移)——方法是:把每段内容包成
727
+ * 临时 design 文件(前置标准 `## 文件变更清单` 标题),写 OS tmp 调 parseFileChangeList,立即清理。
728
+ *
729
+ * 为什么不复用单次 parseFileChangeList:change-list.js 的通用 parser 把段头 `## <repo> 仓变更`
730
+ * 当成「下一个 ## 章节」截断主章节(line 173 `^##\s`),跨仓段路径会丢——这正是 task-03 要修的。
731
+ *
732
+ * 返回值结构(与 pathOwners 二元组对齐):
733
+ * Map<repo, Set<path>> —— 每个 repo 的 design 清单路径集
734
+ * 外加 _hasSegmentHeader 标记(调用方据此决定是否走分段对账路径)
735
+ *
736
+ * @param {string} designPath - design.md 绝对路径
737
+ * @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[] }}
738
+ */
739
+ function parseDesignCoverageByRepo(designPath) {
740
+ const byRepo = new Map()
741
+ const allFiles = []
742
+ if (!designPath || !existsSync(designPath)) {
743
+ return { byRepo, hasSegmentHeader: false, allFiles }
744
+ }
745
+ const content = readFileSync(designPath, 'utf8')
746
+
747
+ const sectionMatch = content.match(FILE_LIST_SECTION_RE)
748
+ if (!sectionMatch) {
749
+ return { byRepo, hasSegmentHeader: false, allFiles }
750
+ }
751
+
752
+ // 主章节起点(match index)→ 扫描到下一个非段头的 `## ` 标题或文件末尾。
753
+ // 段头 `## <repo> 仓变更` 是 h2 但属本章节的子分段,不应触发章节结束。
754
+ const lines = content.slice(sectionMatch.index).split('\n')
755
+ // 先扫一遍:本章节内是否含至少一个段头(决定 hasSegmentHeader / 走分段路径 vs 退化路径)
756
+ let hasSegmentHeader = false
757
+ const sectionLines = []
758
+ for (let i = 0; i < lines.length; i++) {
759
+ const line = lines[i]
760
+ if (i > 0 && /^##\s/.test(line) && !REPO_SECTION_HEADER_RE.test(line)) {
761
+ // 遇到非段头的 h2 标题(如 `## 7. 接口定义`)→ 主章节结束
762
+ break
763
+ }
764
+ if (REPO_SECTION_HEADER_RE.test(line)) hasSegmentHeader = true
765
+ sectionLines.push(line)
766
+ }
767
+
768
+ if (!hasSegmentHeader) {
769
+ // 无段头 → 整章节归 main(等价原 parseFileChangeList 行为)。
770
+ // keepSillyspecDocs=true:.sillyspec/docs/ 模块文档 = 交付物,与 apply 阶段
771
+ // resolveApplyAllowSet 口径一致(债单 D-2)——design 声明的模块文档更新必须被 task 认领。
772
+ const main = new Set(parseFileChangeList(designPath, { keepSillyspecDocs: true }))
773
+ if (main.size > 0) byRepo.set('main', main)
774
+ for (const p of main) allFiles.push(p)
775
+ return { byRepo, hasSegmentHeader: false, allFiles }
776
+ }
777
+
778
+ // 有段头 → 按 repo 切片,每段构造临时 design 调 parseFileChangeList
779
+ // 当前 repo:段头之前的内容(主章节标题到第一个段头之间)归 'main'
780
+ let currentRepo = 'main'
781
+ const segments = new Map() // repo -> 行数组(含表格/列表,不含段头本身)
782
+ let beforeFirstHeader = [] // 段头之前的 main 段内容
783
+ let seenHeader = false
784
+ for (const line of sectionLines.slice(1)) { // 跳过主章节标题行(sectionLines[0])
785
+ const hdr = line.match(REPO_SECTION_HEADER_RE)
786
+ if (hdr) {
787
+ currentRepo = hdr[1]
788
+ seenHeader = true
789
+ if (!segments.has(currentRepo)) segments.set(currentRepo, [])
790
+ continue
791
+ }
792
+ if (!seenHeader) {
793
+ beforeFirstHeader.push(line)
794
+ } else {
795
+ segments.get(currentRepo).push(line)
796
+ }
797
+ }
798
+
799
+ // 段头之前的 main 段(若有内容)也要对账
800
+ if (beforeFirstHeader.filter(l => l.trim()).length > 0) {
801
+ segments.set('main', beforeFirstHeader)
802
+ }
803
+
804
+ const tmpDir = mkdtempSync(pJoin(tmpdir(), 'design-cov-seg-'))
805
+ try {
806
+ for (const [repo, segLines] of segments) {
807
+ // 把段内容包成完整 design md(前置主章节标题,让 parseFileChangeList 识别章节起点)
808
+ const virtualDesign = ['## 文件变更清单', '', ...segLines, ''].join('\n')
809
+ const tmpPath = pJoin(tmpDir, `${repo}.md`)
810
+ writeFileSync(tmpPath, virtualDesign)
811
+ const segFiles = [...parseFileChangeList(tmpPath, { keepSillyspecDocs: true })]
812
+ if (segFiles.length === 0) continue
813
+ const set = byRepo.get(repo) || new Set()
814
+ for (const p of segFiles) { set.add(p); allFiles.push(p) }
815
+ byRepo.set(repo, set)
816
+ }
817
+ } finally {
818
+ rmSync(tmpDir, { recursive: true, force: true })
819
+ }
820
+
821
+ return { byRepo, hasSegmentHeader: true, allFiles }
822
+ }
823
+
824
+ /**
825
+ * design.md 文件变更清单 → tasks allowed_paths 覆盖对账
826
+ *
827
+ * 修复场景:design.md 声明要改某源码文件(如 access-guide UI),但没有任何 task 的
828
+ * allowed_paths 覆盖它 → execute 子代理被 allowed_paths 锁死不能碰它 → UI 没改
829
+ * (「页面还是错的」),而 verify 一路对照 task 卡片循环验证照样 PASS。
830
+ *
831
+ * 在 plan-postcheck(execute 前)确定性拦截:design 清单中每个源码文件必须被
832
+ * 至少一个 task 的 allowed_paths 覆盖(前缀 / glob 容差匹配)。
833
+ *
834
+ * 跨仓分段对账(约束③ / D-014):design §6 支持 `## <repo> 仓变更` 段头按仓分段,
835
+ * task allowed_paths 按 (repo, path) 二元组归属——跨仓 task 的 allowed_paths 只覆盖
836
+ * 对应 repo 段的 design 清单,不与主仓段交叉误判。无段头时整章节归 main(零回归)。
837
+ *
838
+ * fail-open 边界(不阻断):design.md 不存在、无 task 卡片(none 级别)、
839
+ * task 卡片均无 allowed_paths(由 validatePlanFeasibility 把关)。
840
+ * 阻断边界:有 task 卡片但 design 缺清单章节(light/full 已生成 task → design 必有清单)、
841
+ * 清单文件未被任何 task 的 allowed_paths 覆盖。
842
+ *
843
+ * @param {string} changeDir - 变更目录
844
+ * @returns {{ ok: boolean, errors: string[], warnings: string[], designFiles: string[], uncovered: string[] }}
845
+ */
846
+ export function validateDesignFileCoverage(changeDir) {
847
+ const errors = []
848
+ const warnings = []
849
+
850
+ const designPath = pJoin(changeDir, 'design.md')
851
+ if (!existsSync(designPath)) {
852
+ return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
853
+ }
854
+
855
+ const tasksDir = pJoin(changeDir, 'tasks')
856
+ const taskFiles = existsSync(tasksDir)
857
+ ? readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
858
+ : []
859
+
860
+ // 无 task 卡片(plan_level=none 或老变更)→ 无对账对象,fail-open
861
+ if (taskFiles.length === 0) {
862
+ return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
863
+ }
864
+
865
+ // 按仓分段解析 design §6(约束③):段头 `## <repo> 仓变更` → 段内路径归该 repo;无段头 → 全 main
866
+ const { byRepo: designByRepo, hasSegmentHeader, allFiles: designFiles } = parseDesignCoverageByRepo(designPath)
867
+ // 两种断裂文案(缺清单章节 / 文件未覆盖)从 manifest 同源(plan.design-file-coverage.data)。
868
+ const dcRule = getRule('plan.design-file-coverage')
869
+ if (designFiles.length === 0) {
870
+ // 走到 plan-postcheck 说明已生成 task 卡片(light/full),brainstorm 模板规定清单必填。
871
+ // 无清单 = design↔execute 偏差温床(覆盖对账无从对起),阻断,不让它静默放过。
872
+ errors.push(dcRule.data.messageMissingList)
873
+ return { ok: false, errors, warnings, designFiles: [], uncovered: [] }
874
+ }
875
+
876
+ // task allowed_paths 也按 (repo, path) 二元组收集(与 pathOwners 同口径,约束③):
877
+ // 跨仓 task 的 allowed_paths 只覆盖对应 repo 的 design 段,不与主仓段交叉误判。
878
+ const taskAllowedByRepo = new Map() // repo -> string[]
879
+ let totalAllowed = 0
880
+ for (const file of taskFiles) {
881
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
882
+ const repo = parseRepo(content) || 'main'
883
+ const allowed = parseAllowedPaths(content)
884
+ if (allowed.length === 0) continue
885
+ totalAllowed += allowed.length
886
+ const arr = taskAllowedByRepo.get(repo) || []
887
+ arr.push(...allowed)
888
+ taskAllowedByRepo.set(repo, arr)
889
+ }
890
+ if (totalAllowed === 0) {
891
+ return { ok: true, errors, warnings, designFiles, uncovered: [] }
892
+ }
893
+
894
+ // 对账:design 的每条 (repo, path) 必须被同 repo 的 task allowed_paths 覆盖。
895
+ // 无分段(单仓)时 byRepo 只含 main,taskAllowedByRepo 也只含 main → 退化原扁平对账(零回归)。
896
+ const uncovered = []
897
+ for (const [repo, paths] of designByRepo) {
898
+ const allowed = taskAllowedByRepo.get(repo) || []
899
+ for (const df of paths) {
900
+ // 同 repo 无 task 声明 → 整段无覆盖;或有 task 但无 pathMatches → 未覆盖
901
+ if (!allowed.some(ap => pathMatches(df, ap))) {
902
+ uncovered.push(hasSegmentHeader ? `[${repo}] ${df}` : df)
903
+ }
904
+ }
905
+ }
906
+ if (uncovered.length > 0) {
907
+ errors.push(
908
+ dcRule.data.messageUncovered
909
+ .replaceAll('${count}', uncovered.length)
910
+ .replaceAll('${files}', uncovered.map(f => ` • ${f}`).join('\n'))
911
+ )
912
+ }
913
+
914
+ return { ok: errors.length === 0, errors, warnings, designFiles, uncovered }
915
+ }
916
+
917
+ /**
918
+ * Plan 可行性校验器(本地代码证明 execute 前置条件)
919
+ * 检查 TaskCard 的完整性和可行性
920
+ * @param {string} changeDir - 变更目录
921
+ * @param {string} projectRoot - 项目根目录(用于检查 allowed_paths 是否存在)
922
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
923
+ */
924
+ export function validatePlanFeasibility(changeDir, projectRoot = null) {
925
+ const errors = []
926
+ const warnings = []
927
+
928
+ const tasksDir = pJoin(changeDir, 'tasks')
929
+ if (!existsSync(tasksDir)) {
930
+ return { ok: true, errors, warnings } // none/light 可能没有 tasks/
931
+ }
932
+
933
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
934
+ if (taskFiles.length === 0) {
935
+ return { ok: true, errors, warnings }
936
+ }
937
+
938
+ // task 卡片完整 schema 文案从 manifest 同源(plan.task-card-schema);${id} 随检查点取 ${file} / ${taskId || file} / ${taskId}。
939
+ const fsRule = getRule('plan.task-card-schema')
940
+ const allTaskIds = []
941
+ const depMap = new Map()
942
+
943
+ for (const file of taskFiles) {
944
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
945
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
946
+ if (!fmMatch) {
947
+ // 漏闭合 --- 高频坑细化:只报「缺少 YAML frontmatter」不具体,用户须逐轮试错。
948
+ // 有开头 --- 无闭合 / 完全无 --- 分开报并附修复动作(manifest messageFrontmatter 保持
949
+ // 基础句,细化文案内联——同 title_zh 内联先例)。骨架命令从源头保闭合 + LF。
950
+ const firstLine = (content.split('\n')[0] || '').trim()
951
+ errors.push(
952
+ firstLine === '---'
953
+ ? `${file}: frontmatter 未闭合——有开头 --- 但缺少结尾 ---。修复:补一行 --- 闭合(frontmatter 以 --- 行开始并以 --- 行结束),或 sillyspec taskcard <change> --task <task-id> 重新生成安全骨架后填充`
954
+ : `${fsRule.data.messageFrontmatter.replaceAll('${id}', file)}——文件必须以 --- 行开始。修复:sillyspec taskcard <change> --task <task-id> 生成安全骨架后填充`
955
+ )
956
+ continue
957
+ }
958
+ const fm = fmMatch[1]
959
+ const body = content.slice(fmMatch[0].length)
960
+
961
+ // 1. 必要字段检查
962
+ const taskId = (fm.match(/^id:\s*(.+)/m)?.[1] || '').trim()
963
+ const title = (fm.match(/^title:\s*(.+)/m)?.[1] || '').trim()
964
+ // 复用 parseAllowedPaths(认 inline [x] + 块式两种写法),与 blueprint consistency / design coverage
965
+ // 三处统一,消除「feasibility 只认块式 → inline 写法被误报 allowed_paths 为空」的漂移(transcript 卡 3 轮根因)。
966
+ const allowedPaths = parseAllowedPaths(content)
967
+
968
+ if (!taskId) errors.push(fsRule.data.messageId.replaceAll('${id}', file))
969
+ if (!title) errors.push(fsRule.data.messageTitle.replaceAll('${id}', file))
970
+ // title_zh(中文标题)完整性:子代理常为压 plan.js 的 20~40 行硬上限合并 title/title_zh(静默丢失),
971
+ // postcheck 不校验行数(那是 prompt 劝说)但强制 frontmatter 字段齐全(enforcement 优于放宽劝说)。
972
+ // 无对应 manifest 文案,内联(其余字段文案走 fsRule.data.messageXxx)。prompt-control-debt plan-b。
973
+ const titleZh = (fm.match(/^title_zh:\s*(.+)/m)?.[1] || '').trim()
974
+ if (!titleZh) errors.push(`${taskId || file}: frontmatter 缺少 title_zh(中文标题)——不得为压行数合并/删除字段`)
975
+ if (taskId) allTaskIds.push(taskId)
976
+
977
+ // 2. allowed_paths 不为空
978
+ if (allowedPaths.length === 0) {
979
+ errors.push(fsRule.data.messageAllowedPaths.replaceAll('${id}', taskId || file))
980
+ }
981
+
982
+ // 3. allowed_paths 文件存在或父目录存在(仅在 projectRoot 提供时检查)
983
+ if (projectRoot && allowedPaths.length > 0) {
984
+ for (const p of allowedPaths) {
985
+ const fullPath = pJoin(projectRoot, p)
986
+ const parentDir = pJoin(fullPath, '..')
987
+ if (!existsSync(fullPath) && !existsSync(parentDir)) {
988
+ warnings.push(`${taskId}: allowed_paths 中的 ${p} 文件和父目录都不存在`)
989
+ }
990
+ }
991
+ }
992
+
993
+ // 4. depends_on 引用存在
994
+ const dependsOn = parseDependsOn(content)
995
+ if (taskId) depMap.set(taskId, dependsOn)
996
+
997
+ // 5. body/frontmatter 包含必要字段(TaskCard 格式中这些字段在 frontmatter 内)
998
+ const hasGoal = /^goal:/m.test(fm)
999
+ const hasImplementation = /implementation:/m.test(fm)
1000
+ const hasAcceptance = /acceptance:/m.test(fm)
1001
+ const hasVerify = /verify:/m.test(fm)
1002
+ const hasConstraints = /constraints:/m.test(fm)
1003
+
1004
+ if (!hasGoal) errors.push(fsRule.data.messageGoal.replaceAll('${id}', taskId || file))
1005
+ if (!hasImplementation) errors.push(fsRule.data.messageImplementation.replaceAll('${id}', taskId || file))
1006
+ if (!hasAcceptance) errors.push(fsRule.data.messageAcceptance.replaceAll('${id}', taskId || file))
1007
+ if (!hasVerify) errors.push(fsRule.data.messageVerify.replaceAll('${id}', taskId || file))
1008
+ if (!hasConstraints) errors.push(fsRule.data.messageConstraints.replaceAll('${id}', taskId || file))
1009
+
1010
+ // 6. acceptance best-effort 字段 grep(D-05 软约束,warning 不阻断)
1011
+ // 从 acceptance 文本提取 snake_case/camelCase 标识符,grep allowed_paths 指向的源文件;
1012
+ // 找不到 → warning(给 LLM 审查提线索,不阻断 execute)。宁漏不噪:只取 snake/camel,
1013
+ // 正则天然避开命令(无 _ 或大写)/路径(无 /)/中文;glob allowed_path / 目录 / 不存在文件一律跳过。
1014
+ if (projectRoot && hasAcceptance && allowedPaths.length > 0) {
1015
+ let acceptanceText = ''
1016
+ try {
1017
+ const fmObj = jsYaml.load(fm) || {}
1018
+ if (typeof fmObj.acceptance === 'string') acceptanceText = fmObj.acceptance
1019
+ else if (Array.isArray(fmObj.acceptance)) acceptanceText = fmObj.acceptance.join('\n')
1020
+ } catch {
1021
+ // frontmatter 非法 YAML(feasibility 未对 fm 做 YAML 解析,可能字面合法但语义复杂)→ 跳过 best-effort
1022
+ }
1023
+ if (acceptanceText) {
1024
+ const IDENT_RE = /(?<![A-Za-z])[a-z]+(?:_[a-z]+)+|(?<![A-Za-z])[a-z]+(?:[A-Z][a-z]+)+/g
1025
+ const idents = [...new Set(acceptanceText.match(IDENT_RE) || [])]
1026
+ if (idents.length > 0) {
1027
+ const readableFiles = []
1028
+ for (const ap of allowedPaths) {
1029
+ if (ap.includes('*')) continue // glob,跳过(best-effort,不展开)
1030
+ try {
1031
+ readableFiles.push(readFileSync(pJoin(projectRoot, ap), 'utf8'))
1032
+ } catch {
1033
+ // 目录 / 不存在 / 不可读 → 跳过(feasibility 3 已对不存在文件提示 warning)
1034
+ }
1035
+ }
1036
+ // 至少读到一个源文件才比对,避免「全没读到 → 全部标识符误报」噪声
1037
+ if (readableFiles.length > 0) {
1038
+ for (const ident of idents) {
1039
+ if (!readableFiles.some(c => c.includes(ident))) {
1040
+ warnings.push(`${taskId}: acceptance 提到 ${ident} 但 allowed_paths 源文件未命中`)
1041
+ }
1042
+ }
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ // 4b. depends_on 引用存在性
1050
+ for (const [taskId, deps] of depMap) {
1051
+ for (const dep of deps) {
1052
+ if (!depMap.has(dep)) {
1053
+ errors.push(fsRule.data.messageDependsOnMissing.replaceAll('${id}', taskId).replaceAll('${dep}', dep))
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ // 5b. depends_on 无环(topoSortWaves 已含循环检测)
1059
+ const { error: topoError } = topoSortWaves(depMap)
1060
+ if (topoError) {
1061
+ errors.push(topoError)
1062
+ }
1063
+
1064
+ // 7. task id 连续性
1065
+ if (allTaskIds.length > 0) {
1066
+ const nums = allTaskIds.map(id => {
1067
+ const m = id.match(/task-(\d+)/i)
1068
+ return m ? parseInt(m[1], 10) : null
1069
+ }).filter(n => n !== null).sort((a, b) => a - b)
1070
+ // nums[0]!==1 时跳过 = 兼容旧变更编号不从 1 起(契约同 validatePlanForExecute 检查 3,
1071
+ // test/plan-execute-contract.test.mjs Case 10 钉死,勿改)
1072
+ if (nums.length > 0 && nums[0] === 1) {
1073
+ for (let i = 0; i < nums.length; i++) {
1074
+ if (nums[i] !== i + 1) {
1075
+ errors.push(getRule('plan.task-id-continuity').failMessage.replaceAll('${expected}', String(i + 1).padStart(2, '0')).replaceAll('${actual}', String(nums[i]).padStart(2, '0')))
1076
+ break
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ // 9. plan.md 通过 validatePlanForExecute(延迟导入避免循环依赖)
1083
+ // 这在 run.js 的 postcheck contract 中已检查,这里不重复
1084
+
1085
+ return { ok: errors.length === 0, errors, warnings }
1086
+ }
1087
+
1088
+ /**
1089
+ * Plan 产物校验:检查 plan.md 和 tasks/ 是否齐全
1090
+ * @param {string} changeDir - 变更目录
1091
+ * @returns {{ ok: boolean, errors: string[], warnings: string[], planExists: boolean, taskCount: number }}
1092
+ */
1093
+ export function validatePlanArtifacts(changeDir) {
1094
+ const errors = []
1095
+ const warnings = []
1096
+
1097
+ const planPath = pJoin(changeDir, 'plan.md')
1098
+ if (!existsSync(planPath)) {
1099
+ return { ok: false, errors: ['plan.md 不存在'], warnings, planExists: false, taskCount: 0 }
1100
+ }
1101
+
1102
+ const tasksDir = pJoin(changeDir, 'tasks')
1103
+ let taskCount = 0
1104
+ if (existsSync(tasksDir)) {
1105
+ taskCount = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).length
1106
+ } else {
1107
+ warnings.push('tasks/ 目录不存在(plan_level=none 时可接受)')
1108
+ }
1109
+
1110
+ return { ok: true, errors, warnings, planExists: true, taskCount }
1111
+ }
1112
+
1113
+ // ═══════════════════════════════════════════════════════════════
1114
+ // 执行入口(从 run.js 迁移)
1115
+ // ═══════════════════════════════════════════════════════════════
1116
+
1117
+ /**
1118
+ * Plan postcheck 主函数:Wave 重排 + 一致性校验 + 产物确认
1119
+ *
1120
+ * @param {{ cwd: string, specRoot?: string, resolveChangeDir: Function, progress?: object }} context
1121
+ * @throws {Error} 校验失败时抛出
1122
+ */
1123
+ export async function executePlanPostcheck(context) {
1124
+ const { cwd, specRoot, resolveChangeDir, progress } = context
1125
+
1126
+ const specDir = specRoot || pJoin(cwd, '.sillyspec')
1127
+ const changesDir = pJoin(specDir, 'changes')
1128
+ if (!existsSync(changesDir)) {
1129
+ console.warn(' ⚠️ 未找到 changes 目录,跳过 postcheck')
1130
+ return
1131
+ }
1132
+
1133
+ // 找到当前变更目录(progress 由调用方从 SQLite 读取并传入;.runtime/progress.json 已废弃)
1134
+ let changeDir = null
1135
+ if (progress) {
1136
+ changeDir = resolveChangeDir(cwd, progress, specDir)
1137
+ }
1138
+ if (!changeDir) {
1139
+ // 回退:找最新的变更目录
1140
+ const dirs = readdirSync(changesDir)
1141
+ .filter(d => existsSync(pJoin(changesDir, d, 'plan.md')))
1142
+ .sort().reverse()
1143
+ if (dirs.length > 0) changeDir = pJoin(changesDir, dirs[0])
1144
+ }
1145
+ if (!changeDir) {
1146
+ console.warn(' ⚠️ 未找到当前变更目录,跳过 postcheck')
1147
+ return
1148
+ }
1149
+
1150
+ console.log(` 📂 变更目录: ${changeDir}`)
1151
+
1152
+ // ── 报错聚合(坑6③):六个检查全部跑完后统一输出全部失败项,一轮 --done 暴露全部问题,
1153
+ // 不再「失败一个抛一个、修一个冒一个」的迭代盲盒。各检查相互独立(都只读 changeDir 产物),
1154
+ // 前置检查失败不使后续检查失真——唯一例外是 tasks/ 目录缺失时后续检查都无意义,仍提前短路。
1155
+ const failures = [] // { name, errors, hint }
1156
+ const printSectionWarnings = (label, warnings) => {
1157
+ if (warnings.length > 0) {
1158
+ console.warn(`\n⚠️ ${label}警告(不阻断):`)
1159
+ for (const w of warnings) console.warn(` - ${w}`)
1160
+ }
1161
+ }
1162
+
1163
+ // ── 1. 一致性校验 ──
1164
+ const consistency = validateBlueprintConsistency(changeDir)
1165
+ if (consistency.errors.length > 0) {
1166
+ failures.push({ name: '蓝图一致性校验', errors: consistency.errors, hint: '请修复上述问题后重新完成此步骤。' })
1167
+ }
1168
+ printSectionWarnings('蓝图一致性', consistency.warnings)
1169
+
1170
+ // ── 1b. 可行性校验 ──
1171
+ const feasibility = validatePlanFeasibility(changeDir, context.cwd)
1172
+ if (feasibility.errors.length > 0) {
1173
+ failures.push({ name: 'Plan 可行性校验', errors: feasibility.errors, hint: null })
1174
+ }
1175
+ printSectionWarnings('Plan 可行性', feasibility.warnings)
1176
+
1177
+ // ── 1c. 跨任务契约校验 ──
1178
+ // 对账 consumer.expects_from ↔ provider.provides,拦截「consumer 期望字段
1179
+ // 但 provider 未承诺」的契约断裂(避免到 execute/verify 才暴露成 403/500)
1180
+ const crossTask = validateCrossTaskContracts(changeDir)
1181
+ if (crossTask.errors.length > 0) {
1182
+ failures.push({
1183
+ name: '跨任务契约校验(consumer 期望的字段未被 provider 承诺)',
1184
+ errors: crossTask.errors,
1185
+ hint: '修复方式:要么在 provider task 的 provides.fields 补上缺失字段,要么修正 consumer task 的 expects_from.needs(确认依赖是否真实)。',
1186
+ })
1187
+ }
1188
+
1189
+ // ── 1c-b. TaskCard 命令存在性校验 ──
1190
+ // TaskCard verify/implementation 的 `npm|pnpm|yarn run <script>` 必须在 package.json
1191
+ // scripts 中存在(monorepo 子包感知——读 local.yaml modules 块定位)。
1192
+ // invalid → error 硬阻断,避免 execute 子代理跑死命令(design D-04 / 问题 3)。
1193
+ // modules 块可选:无块时仅查根 package.json(与 scan-postcheck 历史行为一致)。
1194
+ const localYamlPath = pJoin(specDir, 'local.yaml')
1195
+ let taskModules = null
1196
+ if (existsSync(localYamlPath)) {
1197
+ taskModules = parseLocalYamlModules(readFileSync(localYamlPath, 'utf8'))
1198
+ }
1199
+ const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules)
1200
+ if (taskCmds.errors.length > 0) {
1201
+ failures.push({
1202
+ name: 'TaskCard 命令存在性校验(verify/implementation 的 npm/pnpm/yarn run <script> 不存在)',
1203
+ errors: taskCmds.errors,
1204
+ hint: '修复方式:要么在 package.json scripts 补上缺失命令(monorepo 注意子包路径),要么修正 TaskCard 的 verify/implementation(确认命令是否真实、是否需 cd 子包)。',
1205
+ })
1206
+ }
1207
+
1208
+ // ── 1d. design 文件覆盖对账 ──
1209
+ // design.md 清单中的每个源码文件必须被某 task 的 allowed_paths 覆盖,
1210
+ // 否则 execute 子代理无权改它 → 漏改(典型表现:「页面还是错的」)。
1211
+ const coverage = validateDesignFileCoverage(changeDir)
1212
+ for (const w of coverage.warnings) console.warn(`\n ⚠️ ${w}`)
1213
+ if (coverage.errors.length > 0) {
1214
+ failures.push({ name: 'design.md 文件覆盖对账(清单中的文件未被任何 task 覆盖)', errors: coverage.errors, hint: null })
1215
+ }
1216
+ if (coverage.designFiles.length > 0 && coverage.uncovered.length === 0) {
1217
+ console.log(` ✅ design.md ${coverage.designFiles.length} 个文件全部被 task allowed_paths 覆盖`)
1218
+ }
1219
+
1220
+ // ── 1e. 阶段完成产物校验(plan stage contract)──
1221
+ // plan 完成 gate 还会跑 stage-contract.validatePlanOutputs;为免「postcheck 通过 →
1222
+ // stage gate 又报 module-impact 缺失/entry-point-wiring 未覆盖」的修一层撞一层,
1223
+ // 在此把 stage contract 的 error/warning 也聚合进本轮输出。
1224
+ const { validatePlanOutputs } = await import('../stage-contract.js')
1225
+ if (typeof validatePlanOutputs === 'function') {
1226
+ const contract = validatePlanOutputs(context.cwd, basename(changeDir), { specRoot })
1227
+ if (contract.errors.length > 0) {
1228
+ failures.push({
1229
+ name: 'plan 阶段产物契约校验(stage-contract validatePlanOutputs)',
1230
+ errors: contract.errors,
1231
+ hint: '修复方式:补缺失产物(如 module-impact.md)、在 design.md 明示不改理由、或修正 task allowed_paths 覆盖 design.md 提及的入口文件。',
1232
+ })
1233
+ }
1234
+ if (contract.warnings.length > 0) {
1235
+ for (const w of contract.warnings) {
1236
+ console.warn(`\n ⚠️ ${w}`)
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ // ── 聚合输出:一轮 --done 暴露全部失败项(坑6③)──
1242
+ if (failures.length > 0) {
1243
+ console.error(`\n❌ plan postcheck 失败(${failures.length} 类问题,已全部列出——一次修复后重跑,无需逐个迭代):`)
1244
+ for (const f of failures) {
1245
+ console.error(`\n 【${f.name}】`)
1246
+ for (const err of f.errors) console.error(` - ${err}`)
1247
+ if (f.hint) console.error(` ${f.hint}`)
1248
+ }
1249
+ throw new Error(`planPostcheck: ${failures.length} 组校验失败(${failures.map(f => f.name).join(';')})`)
1250
+ }
1251
+
1252
+ // ── 2. Wave 重排 ──
1253
+ const tasksDir = pJoin(changeDir, 'tasks')
1254
+ if (existsSync(tasksDir)) {
1255
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
1256
+ const depMap = new Map()
1257
+
1258
+ for (const file of taskFiles) {
1259
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
1260
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
1261
+ let taskId = file.replace('.md', '')
1262
+ if (fmMatch) {
1263
+ const idMatch = fmMatch[1].match(/^id:\s*(.+)/m)
1264
+ if (idMatch) taskId = idMatch[1].trim()
1265
+ }
1266
+ depMap.set(taskId, parseDependsOn(content))
1267
+ }
1268
+
1269
+ const { waves, error: topoError } = topoSortWaves(depMap)
1270
+ if (topoError) {
1271
+ console.error(`\n❌ Wave 重排失败: ${topoError}`)
1272
+ throw new Error('planPostcheck: ' + topoError)
1273
+ }
1274
+
1275
+ console.log('\n 📊 Wave 分组(基于 depends_on 拓扑排序):')
1276
+ waves.forEach((wave, i) => {
1277
+ console.log(` Wave ${i + 1}: ${wave.join(', ')}`)
1278
+ })
1279
+
1280
+ // 比较 plan.md 现有 Wave 分组
1281
+ if (waves.length > 1 && taskFiles.length > 1) {
1282
+ const planPath = pJoin(changeDir, 'plan.md')
1283
+ if (existsSync(planPath)) {
1284
+ const planContent = readFileSync(planPath, 'utf8')
1285
+ if (/##\s*Wave\s+\d/i.test(planContent)) {
1286
+ const existingWaves = []
1287
+ const lines = planContent.split('\n')
1288
+ let currentWaveTasks = null
1289
+ for (const line of lines) {
1290
+ const wm = line.match(/^#+\s*Wave\s+(\d+)/i)
1291
+ if (wm) {
1292
+ if (currentWaveTasks) existingWaves.push(currentWaveTasks)
1293
+ currentWaveTasks = []
1294
+ continue
1295
+ }
1296
+ // 非 Wave 标题行(## 自检 等)退出当前 Wave 段,避免自检 checkbox
1297
+ // 被混入 task 列表导致 Wave 结构比对误判。
1298
+ if (/^#+\s/.test(line)) {
1299
+ if (currentWaveTasks) existingWaves.push(currentWaveTasks)
1300
+ currentWaveTasks = null
1301
+ continue
1302
+ }
1303
+ // 2026-08-20-task-truth-unify:Wave 段下纯 ID 引用行(- task-NN);旧 checkbox 行同收。
1304
+ // 编号补零对齐卡片口径(坑 wave-ref-unpadded,同 parseTaskWavesFromPlan)
1305
+ const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?task-(\d+)\s*[::\s]*$/i)
1306
+ || line.match(/^[-*]\s*\[[ x]\]\s*task-(\d+)/i)
1307
+ if (tm && currentWaveTasks) {
1308
+ currentWaveTasks.push(`task-${String(tm[1]).padStart(2, '0')}`)
1309
+ }
1310
+ }
1311
+ if (currentWaveTasks) existingWaves.push(currentWaveTasks)
1312
+
1313
+ const sameStructure = waves.length === existingWaves.length &&
1314
+ waves.every((w, i) => {
1315
+ const a = [...w].sort().join(',')
1316
+ const b = [...(existingWaves[i] || [])].sort().join(',')
1317
+ return a === b
1318
+ })
1319
+
1320
+ if (sameStructure) {
1321
+ console.log(' ✅ Wave 分组与拓扑排序一致,无需更新 plan.md')
1322
+ } else {
1323
+ console.log(' ⚠️ Wave 分组与拓扑排序不一致,建议手动调整 plan.md')
1324
+ console.log(' 拓扑排序建议的 Wave 分组见上方')
1325
+ }
1326
+ }
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ // ── 3. 产物确认 ──
1332
+ const artifacts = validatePlanArtifacts(changeDir)
1333
+ if (!artifacts.ok) {
1334
+ for (const err of artifacts.errors) console.error(`❌ ${err}`)
1335
+ throw new Error('planPostcheck: artifact validation failed')
1336
+ }
1337
+ console.log('\n ✅ plan.md 存在')
1338
+
1339
+ if (artifacts.taskCount > 0) {
1340
+ console.log(` ✅ tasks/ 目录有 ${artifacts.taskCount} 个 TaskCard 文件`)
1341
+ }
1342
+
1343
+ console.log('\n ✅ Plan postcheck 完成')
1344
+ }