sillyspec 3.25.9 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
@@ -79,7 +79,7 @@ export const definition = {
79
79
  maxWaitRounds: 8,
80
80
  waitReason: '等待用户回答需求问题或澄清',
81
81
  waitOptions: ['回答见--answer', '信息够了,进入方案讨论'],
82
- prompt: `通过对话探索需求细节、分析原型(如有)、判断拆分/批量、并对歧义点做需求澄清 Grill。本步骤合并了原有的对话探索、原型分析、需求范围评估、需求澄清 Grill 四个环节——按下方 A→D 顺序按需执行,能一次问清的不要拆成多轮。
82
+ prompt: `通过对话探索需求细节、分析原型(如有)、判断拆分/批量、并对歧义点做需求澄清 Grill。本步骤合并了原有的对话探索、原型分析、需求范围评估、需求澄清 Grill 四个环节——按下方 A→D 顺序按需执行。提问遵守「一次只问一个」(见 D 节追问策略):必须问的问题逐个问,能查代码/文档解决的不问;不要把多个歧义打包成一屏清单一次抛给用户(用户记不住、答不全,反而漏掉关键约束);也不要为凑数把单个问题拖成多轮。
83
83
 
84
84
  ### A. 原型/设计图分析(如用户提供)
85
85
  如果用户提供了截图、图片或 HTML 原型,先分析提取:
@@ -176,7 +176,7 @@ export const definition = {
176
176
  ### 铁律 — 不要自问自答 / 等待用户
177
177
  - 这是人机协作步骤:提出问题后必须暂停等用户回答,**不要在输出里模拟用户回答然后说"需求已明确"**(命令由 CLI 在下方注入)
178
178
  - 需要拆分或批量模式时:列出方案并暂停等用户确认
179
- - 不需要追问、无歧义、需求已清晰时:可正常完成本步骤(不必强行 --wait)
179
+ - 不需要追问、无歧义、需求已清晰时:可正常完成本步骤(不必强行 --wait)。但「正常完成」的前提是**输出一份可否决的确认清单**——在输出里明确列出「已确认需求点清单」(逐条),让用户能一眼看到并喊停;未与用户确认过任何需求点、纯凭自身判断的场景,不得仅凭「我觉得清晰」直接放行(这是把默认共识伪装成共识的逃逸口)
180
180
  - 2-3 轮问答即进入方案讨论;达到 maxWaitRounds=8 后必须总结已确认内容与剩余风险,不要无限追问
181
181
 
182
182
  ### 输出
@@ -272,6 +272,10 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
272
272
  | 修改 | src/xxx/ExistingFile.java | 新增 xx 方法 |
273
273
  | 删除 | src/xxx/OldFile.java | 已被 xx 替代 |
274
274
 
275
+ **字段数据流标注**(避免新增字段到 execute 才发现没透传):当清单含「新增/修改对外字段、接口、DTO、响应体、事件 payload、配置键」时,对应行「说明」列必须交代 producer→consumer 数据流——产出方(producer)→ 每跳流转/归一化点(序列化/反序列化/字段映射/默认值兜底)→ 消费方(consumer)。漏标 = 字段在某跳 dormant(声明了却没透传),到 execute/verify 才暴露,属设计层缺口。
276
+ - 示例:「修改 daemon.ts:新增 budget_tokens,producer=backend/api-types.ts → ws_hub envelope 透传 → daemon.ts normalizeUsage 归一化(snake→camel)→ consumer=runtime/lease.ts 写 lease」
277
+ - 仅改内部实现、无对外字段变动时,说明列照常写「新增 xx 方法」即可,无需数据流。
278
+
275
279
  7. **接口定义**:方法签名、数据结构(代码类任务必填)
276
280
  7.5. **生命周期契约表**(涉及以下关键词时必填,否则可省略):
277
281
 
@@ -458,7 +462,7 @@ status: passed | needs-user-input | blocked | skipped
458
462
  1. **按规模生成规范文件**:
459
463
  - **scale=large**:在 \`{SPEC_ROOT}/changes/<change-name>/\` 下生成完整四件套(design.md / decisions.md 可选 / proposal.md / requirements.md / tasks.md),实现路径 → \`sillyspec run plan --change <变更名>\`
460
464
  - **scale=small**:只生成/补全 design.md(proposal/requirements/tasks 对 quick 无用,不生成),实现路径 → \`sillyspec run quick --linked-changes <变更名>\`
461
- - 两种规模都执行 \`git add .sillyspec/\` — 暂存规范文件(不要 commit,由用户通过统一提交工具处理)。**平台模式跳过 git add**(specRoot 不在 sourceRoot 的 git repo 内)
465
+ - 两种规模都执行 \`git add {SPEC_ROOT}/changes/<change-name>/\` — 暂存本变更的规范文件(精确到变更目录,勿用 .sillyspec/ 整目录——会裹挟其他活跃变更;不要 commit,由用户通过统一提交工具处理)。**平台模式跳过 git add**(specRoot 不在 sourceRoot 的 git repo 内)
462
466
 
463
467
  2. 生成完成后展示 design.md 摘要 + **规模评估结果(small/large + 一句依据)** + 实现路径建议,告知用户“如有异议直接说,可修改文件、改 scale 或 --reopen 回退”(不暂停,展示完直接 --done)
464
468
 
@@ -472,6 +476,10 @@ created_at: <now-datetime>
472
476
 
473
477
  ### proposal.md 格式要求
474
478
  \`\`\`markdown
479
+ ---
480
+ author: <git-user>
481
+ created_at: <now-datetime>
482
+ ---
475
483
  # 提案书(Proposal)
476
484
 
477
485
  ## 动机
@@ -495,6 +503,10 @@ created_at: <now-datetime>
495
503
 
496
504
  ### requirements.md 格式要求
497
505
  \`\`\`markdown
506
+ ---
507
+ author: <git-user>
508
+ created_at: <now-datetime>
509
+ ---
498
510
  # 需求规格(Requirements)
499
511
 
500
512
  ## 角色
@@ -523,6 +535,18 @@ Then 期望结果
523
535
  | D-001@v1 | FR-01 | ... |
524
536
  \`\`\`
525
537
 
538
+ ### tasks.md 格式要求(scale=large 时生成骨架,plan 阶段展开)
539
+ \`\`\`markdown
540
+ ---
541
+ author: <git-user>
542
+ created_at: <now-datetime>
543
+ ---
544
+ # 任务清单(Tasks)
545
+
546
+ - [ ] task-01: <任务名>
547
+ - [ ] task-02: <任务名>
548
+ \`\`\`
549
+
526
550
  ### decisions.md 格式要求(仅在有 Grill/重大决策时生成)
527
551
  \`\`\`markdown
528
552
  # 决策记录(Decisions)
@@ -549,7 +573,7 @@ Then 期望结果
549
573
  - \`design.md\`:从 MASTER.md 中提取该包的职责描述(标记为「待设计 - 本包 design 在该包进入 brainstorm 时完善」)
550
574
  - \`requirements.md\`:从 MASTER.md 中提取该包的需求范围(标记为「待完善」)
551
575
  - \`tasks.md\`:创建空任务列表,标记为「待 plan 阶段展开」
552
- 4. \`git add .sillyspec/\` — 暂存所有新增文件(不要 commit,由用户通过统一提交工具处理)。**平台模式跳过 git add**(specRoot 不在 sourceRoot 的 git repo 内)
576
+ 4. \`git add {SPEC_ROOT}/changes/<本步骤创建的各变更包名>/\` — 逐个暂存本步骤新建的变更包(勿用 .sillyspec/ 整目录——会裹挟其他活跃变更;不要 commit,由用户通过统一提交工具处理)。**平台模式跳过 git add**(specRoot 不在 sourceRoot 的 git repo 内)
553
577
  5. 后续变更包的骨架文件同样必须包含 \`author: <git-user>\` 和 \`created_at: <now-datetime>\`
554
578
 
555
579
  ### 输出
@@ -563,7 +587,7 @@ Then 期望结果
563
587
  - 如果存在 decisions.md,requirements.md 必须引用全部当前版本 D-xxx@vN;没有覆盖的 D-xxx@vN 必须标注为剩余风险
564
588
  - 如果 Design Grill 产生 P0/P1 unresolved blocker,必须回到 design 修正,不能进入 plan/quick
565
589
  - tasks.md 只列任务名,细节在 plan 阶段展开
566
- - **规范 md 文件第一行标题用中文**(sillyhub 平台解析识别用):tasks.md 第一行用 # 任务清单(Tasks)(proposal/design/requirements/decisions 见各自模板,均已含中文标题)`,
590
+ - **规范 md 文件标题用中文**(sillyhub 平台解析识别用,frontmatter 在标题之前):tasks.md 标题用 # 任务清单(Tasks)(proposal/requirements/tasks/decisions 见各自模板 + design 见「写设计文档并自审」步;均含 frontmatter + 中文标题)`,
567
591
 
568
592
  }
569
593
  ]
@@ -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
  汇总所有检查结果,按以下格式:
@@ -347,7 +350,7 @@ timeout 5 which docker 2>/dev/null && echo "✅ Docker 可用" || echo "ℹ️ D
347
350
 
348
351
  **常见问题及修复:**
349
352
  - CLI 未安装 → \`npm install -g sillyspec\`
350
- - 缺少 local.yaml → \`sillyspec init\` 重新生成,或手动创建
353
+ - 缺少 local.yaml → \`sillyspec local detect\` 重新生成,或手动创建
351
354
  - local.yaml 缺少 test 命令 → 补充对应命令
352
355
  - 缺少 scan 文档(ARCHITECTURE.md 等)→ \`sillyspec run scan\` 重新扫描
353
356
  - sillyspec.db 状态不一致 → \`sillyspec run <阶段> --reset\` 重置对应阶段
@@ -2,6 +2,9 @@ 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'
7
+ import { readMcpConfig } from '../sillyhub-mcp/config.js'
5
8
 
6
9
  /**
7
10
  * 校验 plan.md 是否满足 execute 执行契约
@@ -116,7 +119,7 @@ const fixedPrefix = [
116
119
  1. 读取 tasks.md(执行计划)
117
120
  2. 读取 design.md(技术方案)
118
121
  3. 读取 CONVENTIONS.md、ARCHITECTURE.md
119
- 4. 读取 local.yaml(构建命令)
122
+ 4. 读取 local.yaml(构建命令);若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
120
123
  5. 加载项目总览 \`.sillyspec/docs/<project>/scan/PROJECT.md\`(如存在)
121
124
 
122
125
  ### 模块文档加载
@@ -163,6 +166,7 @@ const fixedPrefix = [
163
166
  1. 运行 \`sillyspec worktree meta <change-name>\` 读取 meta.json
164
167
  2. 从输出中提取 worktreePath、branch、mode 字段
165
168
  3. 确认 worktree 目录存在(如果是 worktree/native-worktree 模式)
169
+ 4. **确认工具链可用**:worktree 内项目工具链(lint/format/test 二进制,如 ruff / prettier / uv)可能不全——对本次会用到的工具先跑一次 \`--version\` 确认;缺失则按项目方式安装(Python 项目 \`uv tool install ruff\` / \`uv sync\`,Node 项目 node_modules 已由 CLI 链接主仓)。不要等到 commit 才发现二进制不在 PATH 被 hook 拦
166
170
 
167
171
  ### 铁律
168
172
  - **worktree 已由 CLI 在 execute 阶段启动时自动创建,不要自行创建或跳过**
@@ -173,7 +177,7 @@ const fixedPrefix = [
173
177
  ### 输出
174
178
  worktree 路径 + 分支名 + 模式
175
179
 
176
- "`,
180
+ `,
177
181
  outputHint: 'worktree 路径 + 分支名 + 模式',
178
182
  optional: false
179
183
  },
@@ -183,17 +187,9 @@ worktree 路径 + 分支名 + 模式
183
187
 
184
188
  ### 操作
185
189
  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 关键词匹配
190
+ 2. 模型档位:若 tasks.md 中某 task 标注了 [model:xxx],启动该 task 子代理时按标签选模型(档位由 plan 阶段或用户在 tasks.md 显式标注,execute 不在此自动建议——关键词→档位无统一映射,自动建议反而易误导)
191
+ 3. 确认频率:默认每个 Wave 完成后展示结果(wave 模式);用户口头指定按 Task 展示或全自动时遵从
192
+ 4. 查询知识库:读取 \`.sillyspec/knowledge/INDEX.md\`,根据 Task 关键词匹配
197
193
 
198
194
  ### 知识命中报告
199
195
  {KNOWLEDGE_HIT_REPORT}
@@ -202,8 +198,7 @@ worktree 路径 + 分支名 + 模式
202
198
  如无命中条目(Status: no matches),跳过本节。
203
199
 
204
200
  ### 铁律
205
- - **不要询问用户确认频率**,确认模式由 CLI \`--confirm-mode\` 参数决定
206
- - 如果未检测到 \`--confirm-mode\`,默认使用 wave 模式`,
201
+ - **不要询问用户确认频率**,默认 wave 模式;用户已明确口头指定时遵从其指定`,
207
202
  outputHint: 'Wave 分组 + 模型分配',
208
203
  optional: false
209
204
  }
@@ -243,12 +238,15 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
243
238
  本步骤由当前 agent 执行,不需要启动独立子代理。
244
239
 
245
240
  ### 操作
246
- 1. 读取 local.yaml 获取构建和测试命令
241
+ 1. 读取 local.yaml 获取构建和测试命令;若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
247
242
  2. 运行测试套件(单元测试、集成测试)
248
- 3. 运行 lint 检查
243
+ 3. 运行 lint 检查 **+ 格式化**:凡变更涉及的源码,既跑 lint check 也跑 formatter(如 \`ruff format\` / \`prettier --write\` / \`black\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截
249
244
  4. 如果有测试失败 → 分析原因,标注是代码问题还是测试本身的问题
250
245
  5. 汇总测试结果
251
246
 
247
+ ### 铁律
248
+ - 长测试/构建/lint 命令必须**前台同步执行**,禁止 run_in_background:true / & / nohup / disown——后台任务易被会话生命周期回收导致中断无果
249
+
252
250
  ### 输出
253
251
  测试结果摘要:通过/失败/跳过数量 + 失败项分析`,
254
252
  outputHint: '测试结果摘要',
@@ -286,7 +284,7 @@ const fixedSuffix = [
286
284
  prompt: `检查本轮执行产生的新知识。
287
285
 
288
286
  ### 操作
289
- 1. 检查 \.sillyspec/knowledge/uncategorized.md\` 中待确认条目
287
+ 1. 检查 \`.sillyspec/knowledge/uncategorized.md\` 中待确认条目
290
288
  2. 如有 → 提示用户审阅
291
289
  3. 用户确认后改为 [已确认],可归类到专题文件
292
290
 
@@ -445,10 +443,27 @@ function parseWavesFromPlan(planContent) {
445
443
  return waves
446
444
  }
447
445
 
446
+ /**
447
+ * 同步判定派发后端模式(不发网络,零回归关键)— task-07
448
+ *
449
+ * stub 下 isPathASupported()=false → 有配置也只 local-fallback(加提示),不注入完整 SillyHub 指令。
450
+ * 现有测试套件不设 SILLYHUB_MCP_URL/TOKEN 且无 local.yaml mcp 段 → readMcpConfig 返回 null → 默认 'local' → buildWavePrompt 不注入派发段,
451
+ * 输出与改前字节一致(零回归,D-005)。
452
+ *
453
+ * @returns {'local' | 'local-fallback' | 'sillyhub'}
454
+ * - local:无 MCP 配置(零回归,与现状一致)
455
+ * - local-fallback:有配置但路径A 未落地(isPathASupported()=false),派发仍走 Local + 短提示
456
+ * - sillyhub:有配置且路径A 落地(isPathASupported()=true),注入完整 SillyHub 派发指令
457
+ */
458
+ export function getDispatchMode() {
459
+ const hasConfig = !!readMcpConfig(process.cwd())
460
+ if (!hasConfig) return 'local'
461
+ return isPathASupported() ? 'sillyhub' : 'local-fallback'
462
+ }
448
463
  /**
449
464
  * 为 Wave 生成 prompt(强制子代理执行)
450
465
  */
451
- function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
466
+ export function buildWavePrompt(wave, waveIndex, changeDir, worktreePath, options = {}) {
452
467
  // ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
453
468
  let contractInjection = ''
454
469
  let prototypeInjection = ''
@@ -563,6 +578,32 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
563
578
  `
564
579
  : ''
565
580
 
581
+ // ── 派发后端段注入(task-07,D-006 / D-007 / D-008 接入)──
582
+ // 同步判定(不发网络,零回归关键):local(无 MCP 配置)→ dispatchSection='' → 本 prompt 与改前
583
+ // 字节一致;sillyhub(配置 + 路径A 落地)→ 注入完整 SillyHub 派发指令(一 Wave 一 mission /
584
+ // dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底);local-fallback
585
+ // (配置但路径A 未落地)→ 短提示,派发仍走 Local(与默认行为一致)。worktreePath 为空时不注入
586
+ // (无 worktree 无谓派发指令)。测试/调用方可经 options.dispatchMode 覆盖,避免 env 污染。
587
+ const dispatchMode = options.dispatchMode || getDispatchMode()
588
+ let dispatchSection = ''
589
+ if (worktreePath && dispatchMode === 'sillyhub') {
590
+ // 路径A 落地后:注入完整 SillyHub 派发指令(renderDispatchInstruction 已含 Local 兜底全文)
591
+ const contract = {
592
+ brief: '本 Wave 任务(见上方任务摘要与 tasks/task-XX.md)',
593
+ worktreePath,
594
+ branch: options.branch || '<未提供 branch>',
595
+ allowedPaths: [],
596
+ readOnly: false,
597
+ runId: '{EXECUTE_RUN_ID}', // 占位符,prompt.js 注入阶段替换为真实 run id
598
+ }
599
+ const { instruction } = renderDispatchInstruction(contract, { available: true })
600
+ dispatchSection = `\n### 派发后端:SillyHub MCP(探测可用,一 Wave 一 mission)\n\n本次 Wave 派发经 SillyHub MCP。按以下派发指令执行(含 create_mission / dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底):\n\n${instruction}\n`
601
+ } else if (worktreePath && dispatchMode === 'local-fallback') {
602
+ // 有 MCP 配置但路径A 未落地:加短提示,派发仍走 Local(与默认行为一致)
603
+ dispatchSection = `\n### 派发后端提示:SillyHub MCP 已配置但路径A 未落地\n\n检测到 local.yaml mcp 段或 env 配置,但 SillyHub \`dispatch_worker\` 尚不支持 \`worktree_path\`(路径A 跨仓未落地)。本次派发走 Local(本机 Agent tool),与默认行为一致——上方「执行方式」与「工作目录」段适用。\n`
604
+ }
605
+ // dispatchMode === 'local'(无配置)或 worktreePath 为空 → dispatchSection = '' → 输出与改前字节一致(零回归)
606
+
566
607
  return `## Wave ${waveIndex}: 执行以下任务
567
608
 
568
609
  ## 执行方式(必须严格遵守)
@@ -575,7 +616,7 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
575
616
  3. 勾选 plan.md 中的 checkbox
576
617
  4. 记录改动文件和测试结果
577
618
 
578
- ${worktreeSection}
619
+ ${worktreeSection}${dispatchSection}
579
620
  ### 任务摘要(按需读取完整蓝图)
580
621
  为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
581
622
 
@@ -616,6 +657,7 @@ ${taskList}
616
657
  - 用户明确要求编译时
617
658
  4. 每个任务完成后:
618
659
  - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
660
+ - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
619
661
  - 记录改动文件和测试结果
620
662
  5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
621
663
 
@@ -654,7 +696,7 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
654
696
  - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
655
697
  - 将端点清单写入 {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
656
698
  - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
657
- "`
699
+ `
658
700
  }
659
701
 
660
702
  /**
@@ -690,7 +732,7 @@ export function buildExecuteSteps(planFilePath = null, options = {}) {
690
732
  const waveSteps = waves.map((wave, i) => ({
691
733
  name: `Wave ${i + 1} 执行`,
692
734
  mode: 'implementation',
693
- prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath),
735
+ prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath, { dispatchMode: options.dispatchMode, branch: options.branch }),
694
736
  outputHint: `Wave ${i + 1} 执行结果`,
695
737
  optional: false
696
738
  }))
@@ -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 子代理无权改它 → 漏改(典型表现:「页面还是错的」)。