sillyspec 3.25.9 → 3.25.11

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 (71) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  8. package/README.md +9 -0
  9. package/package.json +2 -2
  10. package/src/change-list.js +11 -8
  11. package/src/change-risk-profile.js +22 -0
  12. package/src/db.js +160 -159
  13. package/src/dispatch/backends/local-agent.js +152 -0
  14. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  15. package/src/dispatch/probe.js +208 -0
  16. package/src/dispatch/strategy.js +121 -0
  17. package/src/doctor-diagnostics.js +19 -25
  18. package/src/fs-atomic.js +69 -69
  19. package/src/git-helper.js +66 -0
  20. package/src/hooks/worktree-guard.js +64 -55
  21. package/src/index.js +176 -34
  22. package/src/init.js +7 -14
  23. package/src/machine-interface.js +54 -19
  24. package/src/migrate.js +14 -4
  25. package/src/modules.js +16 -5
  26. package/src/progress/change-registry.js +72 -70
  27. package/src/progress/consistency-doctor.js +71 -14
  28. package/src/progress/shared.js +6 -3
  29. package/src/progress/stage-machine.js +61 -65
  30. package/src/progress/step-store.js +62 -67
  31. package/src/progress.js +155 -233
  32. package/src/quicklog.js +13 -5
  33. package/src/run/command.js +65 -37
  34. package/src/run/complete-handlers.js +1005 -889
  35. package/src/run/complete.js +115 -208
  36. package/src/run/concurrent-detect.js +132 -0
  37. package/src/run/gates.js +289 -17
  38. package/src/run/prompt.js +22 -68
  39. package/src/run/quick-audit.js +10 -9
  40. package/src/run/shared.js +648 -600
  41. package/src/run/stage.js +43 -24
  42. package/src/scan-postcheck.js +9 -31
  43. package/src/sillyhub-mcp/client.js +389 -0
  44. package/src/spec-dir-typo.js +71 -0
  45. package/src/stage-contract-engine.js +22 -1
  46. package/src/stage-contract-spec.js +11 -4
  47. package/src/stage-contract.js +26 -3
  48. package/src/stage-review.js +13 -9
  49. package/src/stages/archive.js +4 -6
  50. package/src/stages/brainstorm-auto.js +26 -20
  51. package/src/stages/brainstorm.js +29 -5
  52. package/src/stages/cmd-existence.js +145 -0
  53. package/src/stages/doctor.js +9 -6
  54. package/src/stages/execute.js +61 -20
  55. package/src/stages/plan-postcheck.js +152 -0
  56. package/src/stages/plan.js +16 -31
  57. package/src/stages/quick.js +1 -1
  58. package/src/stages/scan.js +9 -3
  59. package/src/stages/status.js +0 -1
  60. package/src/stages/verify.js +12 -7
  61. package/src/sync.js +4 -4
  62. package/src/task-review.js +30 -1
  63. package/src/verify-postcheck.js +933 -862
  64. package/src/version.js +19 -0
  65. package/src/worktree-apply.js +85 -68
  66. package/src/worktree-deps.js +89 -5
  67. package/src/worktree.js +213 -111
  68. package/templates/prompts/taskcard-rules.md +27 -0
  69. package/templates/prompts/verify-probes.md +3 -3
  70. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  71. package/src/stages/propose.js +0 -181
@@ -0,0 +1,145 @@
1
+ /**
2
+ * cmd-existence.js — 共享命令存在性校验 helper(H2)
3
+ *
4
+ * 抽自 scan-postcheck.js:118-158 的 `npm run <script>` 存在性判定,扩展到 pnpm/yarn,
5
+ * 并感知 monorepo 子目录(`cd <subdir> &&` 前缀 或 local.yaml `modules` 块定位)。
6
+ *
7
+ * 严重度由调用方决定(D-04 同 helper 双严重度):
8
+ * - scan-postcheck 调用时维持 warning(local.yaml 命令误报不阻断 init)
9
+ * - plan-postcheck 调用时升 error(plan 阶段命令更结构化可硬阻断)
10
+ *
11
+ * 仅校验 `npm|pnpm|yarn run <script>` 这一类可静态对账的命令;
12
+ * install / typecheck / npx / uv run 等直接包管理器调用不在范围(沿用 scan-postcheck:126-129 注释立场)。
13
+ */
14
+
15
+ import { existsSync, readFileSync } from 'fs'
16
+ import { join } from 'path'
17
+
18
+ // 提取 `npm|pnpm|yarn run <script>` 命令。
19
+ // 对齐 scan-postcheck:136 的 `/npm run (\S+)/g`,扩展包管理器 + 容忍多空白。
20
+ // 捕获组 1 = 包管理器(npm|pnpm|yarn),捕获组 2 = script 名。
21
+ const SCRIPT_CMD_RE = /(npm|pnpm|yarn)\s+run\s+(\S+)/g
22
+
23
+ // 识别紧贴命令前的 `cd <subdir> &&` 前缀(anchored at end of the slice before the command)。
24
+ // subdir token 不含空白与 & ; | 等分隔符;末尾的 `&&` 可带任意空白后接命令。
25
+ const CD_PREFIX_RE = /cd\s+([^\s&;|()]+)\s*&&\s*$/
26
+
27
+ /**
28
+ * 读取 package.json 的 scripts 字段。
29
+ * @param {string} pkgPath
30
+ * @returns {{ status: 'ok'|'missing'|'unreadable'|'no-scripts', scripts: object|null }}
31
+ * - ok : 文件存在且 JSON 解析成功,含 scripts 对象
32
+ * - missing : 文件不存在
33
+ * - unreadable : 文件存在但 JSON 解析失败(与 scan-postcheck:147 try/catch 一致,不抛)
34
+ * - no-scripts : 解析成功但无 scripts 字段(视作 script 不存在)
35
+ */
36
+ function readScripts(pkgPath) {
37
+ if (!existsSync(pkgPath)) return { status: 'missing', scripts: null }
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
40
+ if (pkg && typeof pkg === 'object' && pkg.scripts && typeof pkg.scripts === 'object') {
41
+ return { status: 'ok', scripts: pkg.scripts }
42
+ }
43
+ return { status: 'no-scripts', scripts: null }
44
+ } catch {
45
+ return { status: 'unreadable', scripts: null }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 为单条命令计算候选 package.json 路径列表。
51
+ *
52
+ * 规则(blueprint task-02 + design §5 H2):
53
+ * - 有 `cd <subdir> &&` 前缀 → 仅查 `<projectRoot>/<subdir>/package.json`(cd 锁定子目录,不回退根)
54
+ * - 无前缀 + modules 块提供 → 查每个 module.path 的子包 + 根 package.json(多候选任一命中即视为存在)
55
+ * - 无前缀 + 无 modules → 仅查 `<projectRoot>/package.json`
56
+ *
57
+ * @param {string} text 全文
58
+ * @param {number} matchIndex 当前命令在 text 中的起始位置
59
+ * @param {string} projectRoot
60
+ * @param {object|null|undefined} modules local.yaml modules 块({ name: { path, test } })
61
+ * @returns {string[]} 候选 package.json 绝对路径列表(至少 1 个)
62
+ */
63
+ function resolveCandidates(text, matchIndex, projectRoot, modules) {
64
+ const before = text.slice(0, matchIndex)
65
+ const cdMatch = before.match(CD_PREFIX_RE)
66
+ if (cdMatch) {
67
+ const subdir = cdMatch[1].replace(/^["']|["']$/g, '')
68
+ return [join(projectRoot, subdir, 'package.json')]
69
+ }
70
+ if (modules && typeof modules === 'object') {
71
+ const candidates = []
72
+ for (const [, mod] of Object.entries(modules)) {
73
+ if (mod && typeof mod === 'object' && typeof mod.path === 'string' && mod.path.trim() !== '') {
74
+ candidates.push(join(projectRoot, mod.path, 'package.json'))
75
+ }
76
+ }
77
+ candidates.push(join(projectRoot, 'package.json'))
78
+ return candidates
79
+ }
80
+ return [join(projectRoot, 'package.json')]
81
+ }
82
+
83
+ /**
84
+ * 校验文本中所有 `npm|pnpm|yarn run <script>` 命令在 package.json scripts 中存在(monorepo 感知)。
85
+ *
86
+ * @param {string} text 待校验文本(local.yaml 内容 / TaskCard verify+implementation 字段等)
87
+ * @param {{ projectRoot: string, modules?: object|null }} opts
88
+ * - projectRoot : 项目根目录绝对路径
89
+ * - modules : local.yaml modules 块(可选),结构如 `{ frontend: { path: "frontend/", test: "..." } }`
90
+ * @returns {{ invalid: Array<{ cmd: string, reason: string }>, checked: number }}
91
+ * - invalid : 找不到 script 的命令列表,每项含 { cmd, reason };reason 区分
92
+ * 「package.json 不存在」「package.json 解析失败」「package.json 无 <script> script」
93
+ * - checked : 从 text 提取到的命令总数(含合法与非法)
94
+ */
95
+ export function validateScriptCommands(text, { projectRoot, modules } = {}) {
96
+ const invalid = []
97
+ if (!text || typeof text !== 'string') {
98
+ return { invalid, checked: 0 }
99
+ }
100
+
101
+ const matches = [...text.matchAll(SCRIPT_CMD_RE)]
102
+ const checked = matches.length
103
+
104
+ for (const m of matches) {
105
+ const pm = m[1]
106
+ const scriptName = m[2]
107
+ const cmd = `${pm} run ${scriptName}`
108
+
109
+ const candidates = resolveCandidates(text, m.index ?? 0, projectRoot, modules)
110
+
111
+ let found = false
112
+ let existedAny = false // 至少一个候选文件存在且可解析(ok 或 no-scripts)
113
+ let parseFailedAny = false // 至少一个候选文件解析失败(unreadable)
114
+ for (const pkgPath of candidates) {
115
+ const result = readScripts(pkgPath)
116
+ if (result.status === 'ok') {
117
+ existedAny = true
118
+ if (Object.prototype.hasOwnProperty.call(result.scripts, scriptName)) {
119
+ found = true
120
+ break
121
+ }
122
+ } else if (result.status === 'no-scripts') {
123
+ existedAny = true
124
+ // scripts 字段缺失 → 该候选肯定不含此 script,继续看其他候选
125
+ } else if (result.status === 'unreadable') {
126
+ parseFailedAny = true
127
+ }
128
+ // missing → 既不算 existed 也不算 parseFailed
129
+ }
130
+
131
+ if (!found) {
132
+ let reason
133
+ if (!existedAny && parseFailedAny) {
134
+ reason = `package.json 解析失败: ${candidates.join(', ')}`
135
+ } else if (!existedAny) {
136
+ reason = `package.json 不存在: ${candidates.join(', ')}`
137
+ } else {
138
+ reason = `package.json 无 ${scriptName} script`
139
+ }
140
+ invalid.push({ cmd, reason })
141
+ }
142
+ }
143
+
144
+ return { invalid, checked }
145
+ }
@@ -63,15 +63,21 @@ if [ ! -f "$DB_FILE" ]; then echo "⚠️ sillyspec.db 不存在"; exit 0; fi
63
63
  node -e "
64
64
  const fs = require('fs');
65
65
  const dir = '.sillyspec/changes';
66
- if (!fs.existsSync(dir)) { console.log('ℹ️ changes/ 目录不存在'); process.exit(0); }
67
- const subs = fs.readdirSync(dir).filter(f => fs.statSync(dir+'/'+f).isDirectory());
68
- if (subs.length === 0) { console.log('ℹ️ 无变更目录'); process.exit(0); }
66
+ const subs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => { try { return fs.statSync(dir+'/'+f).isDirectory(); } catch { return false; } }) : [];
69
67
  const { execSync } = require('child_process');
70
68
  let known;
71
69
  try { const rows = execSync('sqlite3 -json \".sillyspec/.runtime/sillyspec.db\" \"SELECT name FROM changes WHERE status=\\\"active\\\"\"', {encoding:'utf8'}).trim(); known = new Set(JSON.parse(rows).map(r => r.name)); } catch { known = new Set(); }
72
70
  subs.forEach(s => {
73
71
  console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(可清理)');
74
72
  });
73
+ // 反向检查(multi-agent-review Q1):DB active 且匹配 quick-<hex> 但无目录 → 僵尸 quick 会话
74
+ // (quick 收尾未注销,active 行累积污染 listChanges/doctor)。新代码已修收尾,此项清理历史残留。
75
+ const QUICK_RE = /^quick-[0-9a-f]{8}$/;
76
+ const zombieQuick = [...known].filter(n => QUICK_RE.test(n) && !subs.includes(n));
77
+ if (zombieQuick.length > 0) {
78
+ console.log('⚠️ 检测到 ' + zombieQuick.length + ' 个僵尸 quick 会话(DB active 但无目录):');
79
+ zombieQuick.forEach(n => console.log(' - ' + n));
80
+ }
75
81
  "
76
82
  \`\`\`
77
83
 
@@ -131,9 +137,6 @@ else
131
137
  echo ""
132
138
  echo "ℹ️ sillyspec.db 不存在(尚未初始化)"
133
139
  fi
134
- else
135
- echo "ℹ️ gate-status.json 不存在(尚未进入 execute 阶段)"
136
- fi
137
140
  \`\`\`\n
138
141
  ### 输出
139
142
  汇总所有检查结果,按以下格式:
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, readdirSync } from 'fs'
2
2
  import path from 'path'
3
3
  import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjection } from '../contract-matrix.js'
4
4
  import { getRule } from '../stage-contract-spec.js'
5
+ import { renderDispatchInstruction } from '../dispatch/strategy.js'
6
+ import { isPathASupported } from '../dispatch/backends/sillyhub-mcp.js'
5
7
 
6
8
  /**
7
9
  * 校验 plan.md 是否满足 execute 执行契约
@@ -163,6 +165,7 @@ const fixedPrefix = [
163
165
  1. 运行 \`sillyspec worktree meta <change-name>\` 读取 meta.json
164
166
  2. 从输出中提取 worktreePath、branch、mode 字段
165
167
  3. 确认 worktree 目录存在(如果是 worktree/native-worktree 模式)
168
+ 4. **确认工具链可用**:worktree 内项目工具链(lint/format/test 二进制,如 ruff / prettier / uv)可能不全——对本次会用到的工具先跑一次 \`--version\` 确认;缺失则按项目方式安装(Python 项目 \`uv tool install ruff\` / \`uv sync\`,Node 项目 node_modules 已由 CLI 链接主仓)。不要等到 commit 才发现二进制不在 PATH 被 hook 拦
166
169
 
167
170
  ### 铁律
168
171
  - **worktree 已由 CLI 在 execute 阶段启动时自动创建,不要自行创建或跳过**
@@ -173,7 +176,7 @@ const fixedPrefix = [
173
176
  ### 输出
174
177
  worktree 路径 + 分支名 + 模式
175
178
 
176
- "`,
179
+ `,
177
180
  outputHint: 'worktree 路径 + 分支名 + 模式',
178
181
  optional: false
179
182
  },
@@ -183,17 +186,9 @@ worktree 路径 + 分支名 + 模式
183
186
 
184
187
  ### 操作
185
188
  1. 从 plan 中解析 Wave 分组和任务列表
186
- 2. 根据任务描述关键词为每个 Task 建议模型:
187
- - 架构/复杂推理 → 最强模型
188
- - 常规实现 → 中等模型
189
- - 简单修改 → 快速模型
190
- - 文档/写作 → 写作模型
191
- 3. 用户在 tasks.md 中的 [model:xxx] 标签优先
192
- 4. 读取 \`--confirm-mode\` 参数(由 CLI 传入,不需要询问用户):
193
- - wave — 每个 Wave 完成后展示结果(默认)
194
- - task — 每个 Task 完成后展示结果
195
- - auto — 全部自动执行
196
- 5. 查询知识库:读取 \`.sillyspec/knowledge/INDEX.md\`,根据 Task 关键词匹配
189
+ 2. 模型档位:若 tasks.md 中某 task 标注了 [model:xxx],启动该 task 子代理时按标签选模型(档位由 plan 阶段或用户在 tasks.md 显式标注,execute 不在此自动建议——关键词→档位无统一映射,自动建议反而易误导)
190
+ 3. 确认频率:默认每个 Wave 完成后展示结果(wave 模式);用户口头指定按 Task 展示或全自动时遵从
191
+ 4. 查询知识库:读取 \`.sillyspec/knowledge/INDEX.md\`,根据 Task 关键词匹配
197
192
 
198
193
  ### 知识命中报告
199
194
  {KNOWLEDGE_HIT_REPORT}
@@ -202,8 +197,7 @@ worktree 路径 + 分支名 + 模式
202
197
  如无命中条目(Status: no matches),跳过本节。
203
198
 
204
199
  ### 铁律
205
- - **不要询问用户确认频率**,确认模式由 CLI \`--confirm-mode\` 参数决定
206
- - 如果未检测到 \`--confirm-mode\`,默认使用 wave 模式`,
200
+ - **不要询问用户确认频率**,默认 wave 模式;用户已明确口头指定时遵从其指定`,
207
201
  outputHint: 'Wave 分组 + 模型分配',
208
202
  optional: false
209
203
  }
@@ -245,10 +239,13 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
245
239
  ### 操作
246
240
  1. 读取 local.yaml 获取构建和测试命令
247
241
  2. 运行测试套件(单元测试、集成测试)
248
- 3. 运行 lint 检查
242
+ 3. 运行 lint 检查 **+ 格式化**:凡变更涉及的源码,既跑 lint check 也跑 formatter(如 \`ruff format\` / \`prettier --write\` / \`black\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截
249
243
  4. 如果有测试失败 → 分析原因,标注是代码问题还是测试本身的问题
250
244
  5. 汇总测试结果
251
245
 
246
+ ### 铁律
247
+ - 长测试/构建/lint 命令必须**前台同步执行**,禁止 run_in_background:true / & / nohup / disown——后台任务易被会话生命周期回收导致中断无果
248
+
252
249
  ### 输出
253
250
  测试结果摘要:通过/失败/跳过数量 + 失败项分析`,
254
251
  outputHint: '测试结果摘要',
@@ -286,7 +283,7 @@ const fixedSuffix = [
286
283
  prompt: `检查本轮执行产生的新知识。
287
284
 
288
285
  ### 操作
289
- 1. 检查 \.sillyspec/knowledge/uncategorized.md\` 中待确认条目
286
+ 1. 检查 \`.sillyspec/knowledge/uncategorized.md\` 中待确认条目
290
287
  2. 如有 → 提示用户审阅
291
288
  3. 用户确认后改为 [已确认],可归类到专题文件
292
289
 
@@ -445,10 +442,27 @@ function parseWavesFromPlan(planContent) {
445
442
  return waves
446
443
  }
447
444
 
445
+ /**
446
+ * 同步判定派发后端模式(不发网络,零回归关键)— task-07
447
+ *
448
+ * stub 下 isPathASupported()=false → 有配置也只 local-fallback(加提示),不注入完整 SillyHub 指令。
449
+ * 现有测试套件都不设 SILLYHUB_MCP_URL/TOKEN → 默认返回 'local' → buildWavePrompt 不注入派发段,
450
+ * 输出与改前字节一致(零回归,D-005)。
451
+ *
452
+ * @returns {'local' | 'local-fallback' | 'sillyhub'}
453
+ * - local:无 MCP 配置(零回归,与现状一致)
454
+ * - local-fallback:有配置但路径A 未落地(isPathASupported()=false),派发仍走 Local + 短提示
455
+ * - sillyhub:有配置且路径A 落地(isPathASupported()=true),注入完整 SillyHub 派发指令
456
+ */
457
+ export function getDispatchMode() {
458
+ const hasConfig = !!(process.env.SILLYHUB_MCP_URL && process.env.SILLYHUB_MCP_TOKEN)
459
+ if (!hasConfig) return 'local'
460
+ return isPathASupported() ? 'sillyhub' : 'local-fallback'
461
+ }
448
462
  /**
449
463
  * 为 Wave 生成 prompt(强制子代理执行)
450
464
  */
451
- function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
465
+ export function buildWavePrompt(wave, waveIndex, changeDir, worktreePath, options = {}) {
452
466
  // ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
453
467
  let contractInjection = ''
454
468
  let prototypeInjection = ''
@@ -563,6 +577,32 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
563
577
  `
564
578
  : ''
565
579
 
580
+ // ── 派发后端段注入(task-07,D-006 / D-007 / D-008 接入)──
581
+ // 同步判定(不发网络,零回归关键):local(无 MCP 配置)→ dispatchSection='' → 本 prompt 与改前
582
+ // 字节一致;sillyhub(配置 + 路径A 落地)→ 注入完整 SillyHub 派发指令(一 Wave 一 mission /
583
+ // dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底);local-fallback
584
+ // (配置但路径A 未落地)→ 短提示,派发仍走 Local(与默认行为一致)。worktreePath 为空时不注入
585
+ // (无 worktree 无谓派发指令)。测试/调用方可经 options.dispatchMode 覆盖,避免 env 污染。
586
+ const dispatchMode = options.dispatchMode || getDispatchMode()
587
+ let dispatchSection = ''
588
+ if (worktreePath && dispatchMode === 'sillyhub') {
589
+ // 路径A 落地后:注入完整 SillyHub 派发指令(renderDispatchInstruction 已含 Local 兜底全文)
590
+ const contract = {
591
+ brief: '本 Wave 任务(见上方任务摘要与 tasks/task-XX.md)',
592
+ worktreePath,
593
+ branch: options.branch || '<未提供 branch>',
594
+ allowedPaths: [],
595
+ readOnly: false,
596
+ runId: '{EXECUTE_RUN_ID}', // 占位符,prompt.js 注入阶段替换为真实 run id
597
+ }
598
+ const { instruction } = renderDispatchInstruction(contract, { available: true })
599
+ dispatchSection = `\n### 派发后端:SillyHub MCP(探测可用,一 Wave 一 mission)\n\n本次 Wave 派发经 SillyHub MCP。按以下派发指令执行(含 create_mission / dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底):\n\n${instruction}\n`
600
+ } else if (worktreePath && dispatchMode === 'local-fallback') {
601
+ // 有 MCP 配置但路径A 未落地:加短提示,派发仍走 Local(与默认行为一致)
602
+ dispatchSection = `\n### 派发后端提示:SillyHub MCP 已配置但路径A 未落地\n\n检测到 \`SILLYHUB_MCP_URL\`/\`SILLYHUB_MCP_TOKEN\`,但 SillyHub \`dispatch_worker\` 尚不支持 \`worktree_path\`(路径A 跨仓未落地)。本次派发走 Local(本机 Agent tool),与默认行为一致——上方「执行方式」与「工作目录」段适用。\n`
603
+ }
604
+ // dispatchMode === 'local'(无配置)或 worktreePath 为空 → dispatchSection = '' → 输出与改前字节一致(零回归)
605
+
566
606
  return `## Wave ${waveIndex}: 执行以下任务
567
607
 
568
608
  ## 执行方式(必须严格遵守)
@@ -575,7 +615,7 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
575
615
  3. 勾选 plan.md 中的 checkbox
576
616
  4. 记录改动文件和测试结果
577
617
 
578
- ${worktreeSection}
618
+ ${worktreeSection}${dispatchSection}
579
619
  ### 任务摘要(按需读取完整蓝图)
580
620
  为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
581
621
 
@@ -616,6 +656,7 @@ ${taskList}
616
656
  - 用户明确要求编译时
617
657
  4. 每个任务完成后:
618
658
  - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
659
+ - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
619
660
  - 记录改动文件和测试结果
620
661
  5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
621
662
 
@@ -654,7 +695,7 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
654
695
  - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
655
696
  - 将端点清单写入 {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
656
697
  - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
657
- "`
698
+ `
658
699
  }
659
700
 
660
701
  /**
@@ -690,7 +731,7 @@ export function buildExecuteSteps(planFilePath = null, options = {}) {
690
731
  const waveSteps = waves.map((wave, i) => ({
691
732
  name: `Wave ${i + 1} 执行`,
692
733
  mode: 'implementation',
693
- prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath),
734
+ prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath, { dispatchMode: options.dispatchMode, branch: options.branch }),
694
735
  outputHint: `Wave ${i + 1} 执行结果`,
695
736
  optional: false
696
737
  }))
@@ -18,6 +18,7 @@ import { join as pJoin } from 'path'
18
18
  import jsYaml from 'js-yaml'
19
19
  import { parseFileChangeList, pathMatches } from '../change-list.js'
20
20
  import { getRule } from '../stage-contract-spec.js'
21
+ import { validateScriptCommands } from './cmd-existence.js'
21
22
 
22
23
  // ═══════════════════════════════════════════════════════════════
23
24
  // 解析工具(从 plan.js 迁移)
@@ -417,6 +418,100 @@ export function validateCrossTaskContracts(changeDir) {
417
418
  return { ok: errors.length === 0, errors, warnings }
418
419
  }
419
420
 
421
+ /**
422
+ * 从 local.yaml 文本解析 modules 块(monorepo 子包定位)。
423
+ *
424
+ * 与 verify-postcheck.extractModules 同风格(轻量行扫描,不引 yaml 依赖),不复用是为避免
425
+ * plan-postcheck → verify-postcheck → contract-matrix → plan-postcheck 的循环导入。
426
+ * 只取 path 字段(test 字段此处用不到),结构 { name: { path } }。
427
+ *
428
+ * modules:
429
+ * backend: { path: "backend/", test: "cd backend && uv run pytest" }
430
+ *
431
+ * @param {string} yamlText
432
+ * @returns {Record<string, {path:string}>|null} 无 modules 块或无有效条目 → null
433
+ */
434
+ export function parseLocalYamlModules(yamlText) {
435
+ if (!yamlText) return null
436
+ const lines = yamlText.split('\n')
437
+ let startIdx = -1
438
+ for (let i = 0; i < lines.length; i++) {
439
+ if (/^modules:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
440
+ }
441
+ if (startIdx === -1) return null
442
+ const modules = {}
443
+ for (let i = startIdx + 1; i < lines.length; i++) {
444
+ const line = lines[i]
445
+ const entry = line.match(/^ ([A-Za-z0-9_.\-]+):\s*(.*)$/)
446
+ if (!entry) {
447
+ // 遇到新的顶层 key(行首非空格且非注释)→ modules 块结束
448
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
449
+ continue
450
+ }
451
+ const rest = entry[2] || ''
452
+ // path 值:带引号或不带引号(flow mapping),取第一个匹配
453
+ const pathMatch = rest.match(/path:\s*"([^"]+)"/) || rest.match(/path:\s*([^,\s}]+)/)
454
+ if (pathMatch) modules[entry[1]] = { path: pathMatch[1] }
455
+ }
456
+ return Object.keys(modules).length > 0 ? modules : null
457
+ }
458
+
459
+ /**
460
+ * TaskCard 命令存在性校验器(调共享 validateScriptCommands,硬阻断)。
461
+ *
462
+ * 修复场景(design D-04 / 问题 3):TaskCard verify/implementation 写 `pnpm gen:types`
463
+ * 但根 package.json 无此 script(实际在 monorepo 子包 packages 目录下),plan 阶段零校验
464
+ * → execute 子代理跑死命令。scan-postcheck 的命令校验只看 local.yaml 且维持 warning;
465
+ * 本函数对 TaskCard 升 error(同 helper、严重度由调用方定)。
466
+ *
467
+ * modules 从 local.yaml 提取(monorepo 子包感知):无 modules 块时仅查根 package.json
468
+ * (与 scan-postcheck 历史行为一致);有 modules 块时多候选子包 package.json 任一命中即通过。
469
+ *
470
+ * @param {string} changeDir - 变更目录
471
+ * @param {string} projectRoot - 项目根目录(package.json 查找基准)
472
+ * @param {object|null} modules - local.yaml modules 块({ name: { path } }),可选
473
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
474
+ */
475
+ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
476
+ const errors = []
477
+ const warnings = []
478
+
479
+ const tasksDir = pJoin(changeDir, 'tasks')
480
+ if (!existsSync(tasksDir)) {
481
+ return { ok: true, errors, warnings }
482
+ }
483
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
484
+ if (taskFiles.length === 0) {
485
+ return { ok: true, errors, warnings }
486
+ }
487
+
488
+ for (const file of taskFiles) {
489
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
490
+ const taskId = parseTaskId(content, file) || file
491
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
492
+ if (!fmMatch) continue // feasibility 已报 frontmatter 缺失,不重复
493
+ let fm
494
+ try {
495
+ fm = jsYaml.load(fmMatch[1]) || {}
496
+ } catch {
497
+ continue // 非法 YAML 由 feasibility 处理,不重复
498
+ }
499
+
500
+ // 合并 verify + implementation 文本(命令可能出现在任一字段)
501
+ const verifyText = typeof fm.verify === 'string' ? fm.verify : ''
502
+ const implText = typeof fm.implementation === 'string' ? fm.implementation : ''
503
+ const text = `${verifyText}\n${implText}`
504
+ if (!text.trim()) continue
505
+
506
+ const { invalid } = validateScriptCommands(text, { projectRoot, modules })
507
+ for (const inv of invalid) {
508
+ errors.push(`${taskId}: ${inv.cmd} 命令不存在(${inv.reason})`)
509
+ }
510
+ }
511
+
512
+ return { ok: errors.length === 0, errors, warnings }
513
+ }
514
+
420
515
  // 路径容差匹配(normalizePath / globMatch / pathMatches)复用自 change-list.js,
421
516
  // design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
422
517
 
@@ -571,6 +666,44 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
571
666
  if (!hasAcceptance) errors.push(fsRule.data.messageAcceptance.replaceAll('${id}', taskId || file))
572
667
  if (!hasVerify) errors.push(fsRule.data.messageVerify.replaceAll('${id}', taskId || file))
573
668
  if (!hasConstraints) errors.push(fsRule.data.messageConstraints.replaceAll('${id}', taskId || file))
669
+
670
+ // 6. acceptance best-effort 字段 grep(D-05 软约束,warning 不阻断)
671
+ // 从 acceptance 文本提取 snake_case/camelCase 标识符,grep allowed_paths 指向的源文件;
672
+ // 找不到 → warning(给 LLM 审查提线索,不阻断 execute)。宁漏不噪:只取 snake/camel,
673
+ // 正则天然避开命令(无 _ 或大写)/路径(无 /)/中文;glob allowed_path / 目录 / 不存在文件一律跳过。
674
+ if (projectRoot && hasAcceptance && allowedPaths.length > 0) {
675
+ let acceptanceText = ''
676
+ try {
677
+ const fmObj = jsYaml.load(fm) || {}
678
+ if (typeof fmObj.acceptance === 'string') acceptanceText = fmObj.acceptance
679
+ else if (Array.isArray(fmObj.acceptance)) acceptanceText = fmObj.acceptance.join('\n')
680
+ } catch {
681
+ // frontmatter 非法 YAML(feasibility 未对 fm 做 YAML 解析,可能字面合法但语义复杂)→ 跳过 best-effort
682
+ }
683
+ if (acceptanceText) {
684
+ const IDENT_RE = /(?<![A-Za-z])[a-z]+(?:_[a-z]+)+|(?<![A-Za-z])[a-z]+(?:[A-Z][a-z]+)+/g
685
+ const idents = [...new Set(acceptanceText.match(IDENT_RE) || [])]
686
+ if (idents.length > 0) {
687
+ const readableFiles = []
688
+ for (const ap of allowedPaths) {
689
+ if (ap.includes('*')) continue // glob,跳过(best-effort,不展开)
690
+ try {
691
+ readableFiles.push(readFileSync(pJoin(projectRoot, ap), 'utf8'))
692
+ } catch {
693
+ // 目录 / 不存在 / 不可读 → 跳过(feasibility 3 已对不存在文件提示 warning)
694
+ }
695
+ }
696
+ // 至少读到一个源文件才比对,避免「全没读到 → 全部标识符误报」噪声
697
+ if (readableFiles.length > 0) {
698
+ for (const ident of idents) {
699
+ if (!readableFiles.some(c => c.includes(ident))) {
700
+ warnings.push(`${taskId}: acceptance 提到 ${ident} 但 allowed_paths 源文件未命中`)
701
+ }
702
+ }
703
+ }
704
+ }
705
+ }
706
+ }
574
707
  }
575
708
 
576
709
  // 4b. depends_on 引用存在性
@@ -711,6 +844,25 @@ export async function executePlanPostcheck(context) {
711
844
  throw new Error('planPostcheck: cross-task contract check failed')
712
845
  }
713
846
 
847
+ // ── 1c-b. TaskCard 命令存在性校验 ──
848
+ // TaskCard verify/implementation 的 `npm|pnpm|yarn run <script>` 必须在 package.json
849
+ // scripts 中存在(monorepo 子包感知——读 local.yaml modules 块定位)。
850
+ // invalid → error 硬阻断,避免 execute 子代理跑死命令(design D-04 / 问题 3)。
851
+ // modules 块可选:无块时仅查根 package.json(与 scan-postcheck 历史行为一致)。
852
+ const localYamlPath = pJoin(specDir, 'local.yaml')
853
+ let taskModules = null
854
+ if (existsSync(localYamlPath)) {
855
+ taskModules = parseLocalYamlModules(readFileSync(localYamlPath, 'utf8'))
856
+ }
857
+ const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules)
858
+ if (taskCmds.errors.length > 0) {
859
+ console.error('\n❌ TaskCard 命令存在性校验失败(verify/implementation 的 npm/pnpm/yarn run <script> 不存在):')
860
+ for (const err of taskCmds.errors) console.error(` - ${err}`)
861
+ console.error('\n 修复方式:要么在 package.json scripts 补上缺失命令(monorepo 注意子包路径),')
862
+ console.error(' 要么修正 TaskCard 的 verify/implementation(确认命令是否真实、是否需 cd 子包)。')
863
+ throw new Error('planPostcheck: task command existence check failed')
864
+ }
865
+
714
866
  // ── 1d. design 文件覆盖对账 ──
715
867
  // design.md 清单中的每个源码文件必须被某 task 的 allowed_paths 覆盖,
716
868
  // 否则 execute 子代理无权改它 → 漏改(典型表现:「页面还是错的」)。
@@ -198,7 +198,7 @@ light 计划的约束:
198
198
  - **禁止**泛泛风险分析(如"需要充分测试")
199
199
  - **禁止**放实现细节(函数签名、代码示例)
200
200
  - 来源/目标直接引用已有文档,不重新生成
201
- - 如果存在 decisions.md,所有当前版本 D-xxx@vN 必须在 Tasks 或覆盖矩阵中出现
201
+ - 如果存在 decisions.md,所有当前版本 D-xxx@vN 必须在 Tasks 或覆盖矩阵中出现(CLI 只校验 D-xxx@vN ID 字面出现在 plan.md,warning 不阻断;矩阵结构供人类追溯,CLI 不校验 D→FR→task 映射完整性)
202
202
  - 如果存在 P0/P1 unresolved blocker,不生成 plan.md
203
203
  - 任务列表控制在 10 条以内
204
204
  - **任务必须使用 checkbox 格式**(\`- [ ] task-XX:\`),不要用纯编号列表(\`1. 2.\`),execute 阶段依赖此格式解析任务
@@ -256,7 +256,7 @@ full 计划的约束:
256
256
  - Mermaid 依赖关系图**仅当依赖关系非平凡时生成**(线性依赖或全并行时不生成)
257
257
  - **Wave 下的 checkbox 行必须保留**(execute 阶段解析依赖 \`- [ ] task-XX:\` 格式)
258
258
  - plan.md 包含 Wave 分组 + 任务总表 + 关键路径 + 全局验收标准,**不放实现细节**
259
- - 如果存在 decisions.md,plan.md 必须包含当前版本 D-xxx@vN/FR-xxx 覆盖矩阵
259
+ - 如果存在 decisions.md,plan.md 建议包含当前版本 D-xxx@vN/FR-xxx 覆盖矩阵(CLI 只校验 D-xxx@vN/FR-xxx ID 字面出现在 plan.md,warning 不阻断;矩阵的 D→FR→task 映射完整性供人类追溯,CLI 不校验——勿以为画了矩阵就被结构校验)
260
260
  - 如果存在 P0/P1 unresolved blocker,不生成 plan.md,输出阻塞清单
261
261
  - 实现细节写到后续的 tasks/task-NN.md 中
262
262
  - 每个任务编号格式:task-01、task-02 ...
@@ -308,8 +308,17 @@ plan_level + 计划内容(审查在下一步独立进行)`,
308
308
  const stepReviewPlan = {
309
309
  id: 'review_plan',
310
310
  name: '审查计划',
311
+ conditionalWait: true,
312
+ waitReason: '等待用户确认计划后进入执行',
313
+ waitOptions: ['确认,进入执行', '需要调整'],
311
314
  prompt: `对上一步生成的 plan.md 做审查。生成与审查分离——不在生成 plan 的同一上下文里自审,避免确认偏差。
312
315
 
316
+ ### 执行前确认门(plan_level=full 时)
317
+ plan.md 审查通过后、进入 execute 前,若 plan_level=full(跨模块/大变更),必须先向用户展示计划摘要并等待确认,不要让 AI 默认共识擅自开工:
318
+ - 展示:Wave 分组 + task 总数 + 关键 allowed_paths 边界 + 依赖说明(一两屏内,不贴全文)
319
+ - 调用:\`sillyspec run plan --wait --reason "等待用户确认计划" --options "确认,进入执行,需要调整" --output "计划摘要"\`
320
+ - 用户确认后再 --done;plan_level=none/light(小变更)无需等待,正常完成即可
321
+
313
322
  ### 当前审查分级(CLI 按变更规模判定,占位符由 run.js 注入)
314
323
  tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
315
324
  - tier=self:当前 agent 直接执行下方审查清单(小变更,独立审查仪式成本 > 收益)
@@ -323,7 +332,8 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
323
332
  - [ ] 不存在 P0/P1 unresolved blocker 残留
324
333
  - [ ] 没有实现细节泄漏到 plan.md(接口签名/代码示例应在 tasks/task-NN.md)
325
334
  - [ ] 关键路径与 Wave 依赖合理(无循环依赖、无遗漏前置)
326
- - [ ] 连带测试归属:改共享/被多 task 依赖源文件的 task 是否在 related_tests 声明了会失效的既有测试、且路径在 allowed_paths 内(或由独立测试 task 覆盖)?(漏声明 = execute 阶段测试债、主代理事后兜底)
335
+ - [ ] 连带测试归属:本批改动是否会导致既有测试断言失效(改共享/被多 task 依赖源文件、改被测试精确匹配的值如 UI 文案/按钮文本/错误信息/常量/枚举字面量、改函数签名或返回结构等单文件场景)?此类 task 是否在 related_tests 声明了失效测试、且路径在 allowed_paths 内(或由独立测试 task 覆盖)?(漏声明 = execute 阶段测试债、主代理事后兜底)
336
+ - [ ] acceptance 字段对照实际 schema/类型源文件核验存在性与形态,不凭 design.md 文字臆断(plan-postcheck best-effort grep 会给 allowed_paths 源文件未命中的 snake_case/camelCase 标识符提 warning,此处是语义层复查;臆断 = execute 阶段返工)
327
337
 
328
338
  ### tier=independent 时:启动 plan-review 子代理
329
339
  用 Agent tool 启动子代理(subagent_type: general),prompt 要点:
@@ -403,37 +413,12 @@ verify:
403
413
  constraints:
404
414
  - 边界约束 1(如:不加测试)
405
415
  - 边界约束 2(如:不修改传入参数)
406
- related_tests: # 可选。仅当本 task 改共享/被多 task 依赖的源文件时填
416
+ related_tests: # 可选。当本 task 改动会导致既有测试断言失效时填(共享源文件/UI文案/常量/签名变更等,判据=测试是否失败非文件是否共享)
407
417
  - path: frontend/src/lib/errors.test.ts # 因本次改动断言失效的既有测试文件
408
418
  reason: 旧断言假设单例,改归属键后需同步
409
419
  ---
410
420
 
411
- TaskCard 格式规则(必须严格遵守):
412
- - 总长度 20~40 行,不要写成长文档
413
- - frontmatter 只含必要字段,不加 estimated_hours
414
- - goal: 一句话,用 > 多行字符串
415
- - implementation: 列表,每条一个具体步骤
416
- - acceptance: 列表,每条可独立验证(不是表格)
417
- - verify: 列表,实际可执行的命令
418
- - constraints: 列表,明确边界(含 brownfield 兼容、异常处理)
419
- - 不需要:修改文件章节、覆盖来源章节、接口定义章节、TDD 步骤章节、参考章节
420
- - frontmatter 必须以三减号结尾闭合(开头和结尾各一行 ---)。缺结尾会让 postcheck 提取不到 frontmatter,误报「字段缺失 / allowed_paths 为空」
421
- - allowed_paths 用块式写法(键名换行后每项一行「 - 路径」),不要用流式方括号 [path]——块式与所有校验器一致最稳
422
- - implementation / acceptance / constraints / verify 的每条列表项是 YAML plain scalar,避免以下特殊字符触发 ScannerError:
423
- - 冒号+空格(如代码签名 provider_id:str、queryUsage(id:string))会被当映射键报 mapping values are not allowed here——去掉冒号后空格或改成中文描述
424
- - 花括号且内部含冒号或引号(如 JS 对象、JS 模板字面量)会报 expected block end——改用不含这些字符的中文描述
425
- - 引号不成对会报扫描错误
426
- - 注:goal 用 > 折叠标量可容忍花括号,goal 里写占位符不炸;只有上述 plain scalar 列表项需注意
427
- - provides / expects_from 是可选字段:仅当跨 task 契约(一个 task 的接口/DTO/响应被另一个 task 消费)时才填,单 task 或无对外接口场景留空即可
428
- - 填写后 plan-postcheck 会做硬对账:consumer 的每个 expects_from[provider].needs 字段必须在对应 provider 的 provides.fields 里,否则 plan 阶段阻断(不进入 execute)
429
- - 不要把内部实现字段塞进 provides;只暴露给其他 task 的对外契约形状
430
- - related_tests 是可选字段:仅当本 task 改共享源文件(被既有测试引用、或被多 task 依赖)时填,列出会因本次改动断言失效的既有测试文件 + 为何失效。关键:这些测试路径必须同时写进本 task 的 allowed_paths(否则子代理被 allowed_paths 铁律锁死、改不了测试 → 退化成主代理事后兜底);若测试由独立的测试 task 负责,则在该测试 task 的 allowed_paths 覆盖、本字段留空
431
- - 如果存在 decisions.md,无法覆盖的 D-xxx@vN 在 constraints 中标注
432
- - **保存前格式自检**(plan-postcheck 会硬校验,不通过到 Step 4 会批量报错,先在这里逐条自查):
433
- - frontmatter 字段齐全:id、title、title_zh、author、created_at、priority、depends_on、blocks、allowed_paths、goal、implementation、acceptance、verify、constraints
434
- - allowed_paths 非空(至少一个真实源文件路径;回归类 task 无源码改动时填被验证的关键入口文件)
435
- - acceptance 与 verify 都在 frontmatter 内(漏写会被 plan-postcheck 报「缺少验收标准」/「缺少验证步骤」)
436
- - 写完后用 Write tool 保存到文件
421
+ {{include: taskcard-rules}}
437
422
  \`\`\``
438
423
  }).join('\n\n')
439
424
 
@@ -470,7 +455,7 @@ ${subagentPrompts}
470
455
  - allowed_paths 有无冲突
471
456
  - depends_on 与 plan.md Wave 分组是否一致
472
457
  - provides/expects_from 契约自洽:每个 expects_from[provider].needs 字段都在该 provider task 的 provides.fields 里(plan-postcheck 会硬校验,这里提前自查)
473
- - related_tests(若填)的测试路径是否都在本 task 或某 task 的 allowed_paths 内(否则子代理无权改 → execute 测试债、主代理事后兜底)
458
+ - related_tests 判据 = 是否有既有测试因本次改动而失败(非「源文件是否共享」;UI 文案/常量/签名变更等单文件场景也算);若填,测试路径必须都在本 task 或某 task 的 allowed_paths 内(否则子代理无权改 → execute 测试债、主代理事后兜底)
474
459
  - 如发现矛盾,列出问题清单,不要自动修复`
475
460
 
476
461
  return {
@@ -10,7 +10,7 @@ export const definition = {
10
10
 
11
11
  ### 📌 本 quick 会话 sessionId: <quick-session-id>
12
12
  - CLI 是短进程,run 与 done 是独立进程,sessionId 靠 --change 跨进程传递
13
- - **完成每个 step 必须带 --change <quick-session-id>**(命令由 CLI 在下方注入);多会话并发时不带会命中他者会话状态(不带时 fallback 读 current-quick-run-id,单会话兼容;多会话不可靠)
13
+ - **完成每个 step 必须带 --change <quick-session-id>**(命令由 CLI 在下方注入);多会话并发时不带会命中他者会话状态
14
14
 
15
15
  ### 操作
16
16
  1. 检查关联变更(\`<linked-changes>\`,逗号分隔的变更名列表;显示「(无)」= 不关联变更),确定记录方式