sillyspec 3.25.8 → 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 +215 -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
package/src/run/stage.js CHANGED
@@ -19,11 +19,12 @@
19
19
  import { join, dirname } from 'node:path'
20
20
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
- import { resolveSpecDir, resolveChangeDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
22
+ import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
24
  import { outputStep } from './prompt.js'
25
25
  import { allocateQuicklogEntry, deriveTitleFromLinkedChange } from '../quicklog.js'
26
26
  import { checkTransition } from '../stage-contract.js'
27
+ import { completeStageGates } from './gates.js'
27
28
 
28
29
  export async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
29
30
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
@@ -89,7 +90,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
89
90
  if (stageName === 'execute') {
90
91
  const { generateExecuteRunId } = await import('../task-review.js')
91
92
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
92
- const runtimeRoot = platformOpts?.runtimeRoot || join(execSpecBase, '.runtime')
93
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
93
94
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
94
95
  mkdirSync(runtimeRoot, { recursive: true })
95
96
  // 优先读取已有的变更专属标记文件
@@ -107,7 +108,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
107
108
  // 自动探测 currentChange
108
109
  if (autoDetectChange(progress, cwd)) {
109
110
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
110
- await pm._write(cwd, progress, changeName)
111
+ pm._write(cwd, progress, changeName)
111
112
  triggerSync(cwd, changeName, platformOpts)
112
113
  }
113
114
 
@@ -121,7 +122,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
121
122
  if (progress.currentStage !== stageName) {
122
123
  progress.currentStage = stageName
123
124
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
124
- await pm._write(cwd, progress, changeName)
125
+ pm._write(cwd, progress, changeName)
125
126
  triggerSync(cwd, changeName, platformOpts)
126
127
  }
127
128
 
@@ -143,7 +144,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
143
144
  // stale 步骤视为可执行(等同于 pending)
144
145
  if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
145
146
  steps[currentIdx].status = 'pending'
146
- await pm._write(cwd, progress, changeName)
147
+ pm._write(cwd, progress, changeName)
147
148
  triggerSync(cwd, changeName, platformOpts)
148
149
  }
149
150
 
@@ -159,7 +160,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
159
160
  }
160
161
  // 保存 profile 供后续 postcheck 使用
161
162
  stageData.scanProfile = scanProfile
162
- await pm._write(cwd, progress, changeName)
163
+ pm._write(cwd, progress, changeName)
163
164
  } else if (stageName === 'scan' && stageData.scanProfile) {
164
165
  scanProfile = stageData.scanProfile
165
166
  }
@@ -194,8 +195,8 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
194
195
  // 旧逻辑无条件清空 steps 为 pending 会丢掉已完成的进度且不可恢复。改为走正式
195
196
  // completeStage:产物齐则补盖完成戳,不齐则给出 actionable 提示,永不静默清空步骤。
196
197
  console.log(`\nℹ️ ${stageName} 所有步骤已完成,但阶段未标记完成(上次可能中断)。尝试补盖完成戳…`)
197
- await pm.completeStage(cwd, stageName, changeName)
198
- const after = await pm.read(cwd, changeName)
198
+ pm.completeStage(cwd, stageName, changeName)
199
+ const after = pm.read(cwd, changeName)
199
200
  if (after?.stages?.[stageName]?.status === 'completed') {
200
201
  console.log(` ✅ 已补盖完成戳。下一步: sillyspec run <下一阶段>,或 sillyspec run ${stageName} --status 查看。`)
201
202
  process.exit(0)
@@ -212,7 +213,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
212
213
  // (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
213
214
  // prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
214
215
  if (stageName === 'quick') {
215
- const sessionGuardDir = join(specBase, '.runtime', 'quick-sessions', changeName)
216
+ // quick-sessions 目录经 resolveQuickSessionsDir 单一解析(multi-agent-review Q4):
217
+ // 平台模式 runtimeRoot 与 specBase/.runtime 不同时,写/读须对齐,否则 guard 写一处、收尾读另一处。
218
+ const sessionGuardDir = join(resolveQuickSessionsDir(platformOpts, specBase), changeName)
216
219
  const guardFile = join(sessionGuardDir, 'guard.json')
217
220
  let existingGuard = null
218
221
  try {
@@ -222,9 +225,19 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
222
225
  // 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
223
226
  progress.quickGuard = existingGuard
224
227
  } else {
228
+ // baseline 采集用 safeGit(带 -c safe.directory,避免 linked worktree/容器异 uid/Windows 挂载点
229
+ // 下裸 `git status` 抛错)。safeGit 已消除 safe.directory 类失败;若仍失败(真非 git 目录等),
230
+ // 不硬阻断 quick 启动(平台模式/非 git 目录仍需渲染 step prompt),但 fail-visible:大声 warn +
231
+ // baseline 置空。--done 时 auditQuickCompletion 的 safeGit 同样失败 → blocked,故不存在「静默
232
+ // 完成」路径(multi-agent-review Q3)。
233
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
234
+ if (statusResult.error) {
235
+ console.warn(`⚠️ quick baseline 采集失败(git status): ${statusResult.error}`)
236
+ console.warn(` baseline 置空;--done 审计将因 git 不可用而阻断(无静默完成路径)。`)
237
+ console.warn(` 排查:仓库 safe.directory 配置 / 非 git 目录。`)
238
+ }
225
239
  try {
226
- const { execSync } = await import('child_process')
227
- const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
240
+ const gitStatus = statusResult.value || ''
228
241
  // 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
229
242
  // (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
230
243
  // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
@@ -275,7 +288,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
275
288
  if (allowNew) parts.push('允许新增文件')
276
289
  console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
277
290
  console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
278
- await pm._write(cwd, progress, changeName)
291
+ pm._write(cwd, progress, changeName)
279
292
  } catch (e) {
280
293
  console.warn(`⚠️ baseline 记录失败: ${e.message}`)
281
294
  }
@@ -328,7 +341,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
328
341
  }
329
342
  stageData.steps[currentIdx].status = 'completed'
330
343
  stageData.steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
331
- await pm._write(cwd, progress, changeName)
344
+ pm._write(cwd, progress, changeName)
332
345
  // 自动前进到下一步
333
346
  const nextIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
334
347
  if (nextIdx !== -1 && defSteps[nextIdx]) {
@@ -338,7 +351,13 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
338
351
  // 所有步骤完成
339
352
  stageData.status = 'completed'
340
353
  stageData.completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
341
- await pm._write(cwd, progress, changeName)
354
+ // persist _write 移到 completeStageGates 成功之后(task-02 / review-2026-08-09 #2):gate 异常/失败 → rollback 回 in-progress 落盘,此处未到 _write,DB 不留假 completed。
355
+ // 阶段完成收尾共享管线(noAI 末步核心修复 S1:plan postcheck independent-tier review verdict=fail /
356
+ // 平台 scan manifest 此前被绕过)。gate 失败已 rollback 为 in-progress,early-return(不 fall through 到末尾 return)。
357
+ const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText: null })
358
+ if (_stageGatesResult) return _stageGatesResult
359
+ // gate 全过:persist completed(task-02 移后;此处无 triggerSync,与同文件 noAI 末步语义一致)。
360
+ pm._write(cwd, progress, changeName)
342
361
  console.log(`\n✅ ${stageName} 阶段全部完成。`)
343
362
  }
344
363
  return
@@ -390,20 +409,20 @@ async function executePlanPostcheck(cwd, platformOpts, progress) {
390
409
 
391
410
  /**
392
411
  * execute 入口 deps 自检(D-002,change 2026-06-28-worktree-deps-provision)。
393
- * 已存在 worktree(create short-circuit 不供给)时,校验 depsStatus 缺失 / node_modules
394
- * 丢失(missing) / lockfile 变化(stale) → 触发 provisionDeps 重供给并写回 meta。
412
+ * 已存在 worktree(create short-circuit 不供给)时,校验 deps 状态缺失/漂移 → 触发 provisionDeps 重供给并写回 meta。
413
+ *
414
+ * 判定改调共享 checkDepsFreshness(H1,change 2026-08-05-tooling-feedback-fixes task-04):
415
+ * - status ∈ {missing, stale, main-drift, failed} → 触发重供给(main-drift 为新增主仓 lockfile 漂移触发)
416
+ * - status === fresh → 跳过
417
+ * 行为与原内联 noStatus/missing/stale 三判定等价(provisionDeps 调用 / meta 写回不变),新增 main-drift 触发。
395
418
  */
396
419
  async function ensureDepsFreshness(cwd, changeName, specBase, worktreeMeta) {
397
420
  if (!worktreeMeta || !worktreeMeta.worktreePath) return
398
- const { provisionDeps, lockfileHash } = await import('../worktree-deps.js')
421
+ const { provisionDeps, checkDepsFreshness } = await import('../worktree-deps.js')
399
422
  const wtPath = worktreeMeta.worktreePath
400
- const nodeModulesExists = existsSync(join(wtPath, 'node_modules'))
401
- const currentHash = lockfileHash(wtPath)
402
- const noStatus = !worktreeMeta.depsStatus
403
- const missing = ['linked', 'installed'].includes(worktreeMeta.depsStatus) && !nodeModulesExists
404
- const stale = !!(worktreeMeta.depsLockHash && currentHash && currentHash !== worktreeMeta.depsLockHash)
405
- if (!noStatus && !missing && !stale) return
406
- const reason = noStatus ? 'depsStatus 缺失' : (missing ? 'node_modules 丢失' : 'lockfile 变化')
423
+ const fresh = checkDepsFreshness(worktreeMeta, wtPath, cwd)
424
+ if (fresh.status === 'fresh') return
425
+ const reason = fresh.detail || fresh.status
407
426
  console.log(`🔄 worktree deps 自检:${reason}(depsStatus=${worktreeMeta.depsStatus || 'unknown'}),重新供给...`)
408
427
  let deps = {}
409
428
  try {
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
  import { SCAN_STATUS, CHECK_SEVERITY, SCAN_REQUIRED_DOCS, SCAN_REQUIRED_DOCS_QUICK } from './constants.js'
11
+ import { validateScriptCommands } from './stages/cmd-existence.js'
11
12
 
12
13
  const REQUIRED_SCAN_DOCS = SCAN_REQUIRED_DOCS
13
14
 
@@ -119,40 +120,17 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
119
120
  const localYamlPath = join(specDir, 'local.yaml')
120
121
  if (existsSync(localYamlPath)) {
121
122
  const yamlContent = readFileSync(localYamlPath, 'utf8')
122
- const packageJsonPath = join(cwd, 'package.json')
123
- const invalidCommands = []
124
-
125
- // 简单提取 local.yaml 中的 commands
126
- // 注意(X-3, change 2026-06-28-worktree-deps-provision):仅校验 build/test/lint 这类
127
- // `npm run <script>` 形式命令在 package.json scripts 中存在。install/typecheck 是直接
128
- // 包管理器调用(pnpm install / npm ci / npx tsc),不是 npm script,不在此校验范围——
129
- // 下方的 /npm run (\S+)/g 正则天然不会匹配它们,无需也不应对其做 scripts 校验。
130
- const commandMatch = yamlContent.match(/build:\s*"([^"]+)"/) ||
131
- yamlContent.match(/test:\s*"([^"]+)"/) ||
132
- yamlContent.match(/lint:\s*"([^"]+)"/)
133
-
134
- if (commandMatch) {
135
- // 提取所有 npm run <script> 形式的命令
136
- const npmRunCommands = yamlContent.match(/npm run (\S+)/g) || []
137
- if (npmRunCommands.length > 0 && existsSync(packageJsonPath)) {
138
- try {
139
- const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
140
- const scripts = pkg.scripts || {}
141
- for (const cmd of npmRunCommands) {
142
- const scriptName = cmd.replace('npm run ', '')
143
- if (!scripts[scriptName]) {
144
- invalidCommands.push(`${cmd} (package.json 无 ${scriptName} script)`)
145
- }
146
- }
147
- } catch {}
148
- }
149
- }
150
-
151
- if (invalidCommands.length > 0) {
123
+ // 改调共享 validateScriptCommands(H2,去重 plan/scan 两处命令校验)。
124
+ // 严重度维持 WARNING(design D-04:scan 阶段保守,local.yaml 命令误报不阻断 init);
125
+ // 不传 modules → 仅查根 package.json,与历史行为一致(monorepo 子包感知由 plan-postcheck 启用)。
126
+ // 仅校验 `npm|pnpm|yarn run <script>` 这类可静态对账的命令;install/typecheck/npx 等直接
127
+ // 包管理器调用不在范围(沿用 cmd-existence.js:126-129 注释立场,正则天然不匹配它们)。
128
+ const { invalid } = validateScriptCommands(yamlContent, { projectRoot: cwd })
129
+ if (invalid.length > 0) {
152
130
  checks.push({
153
131
  name: 'local_config_invalid',
154
132
  severity: CHECK_SEVERITY.WARNING,
155
- detail: `local.yaml 引用不存在的命令: ${invalidCommands.join('; ')}`
133
+ detail: `local.yaml 引用不存在的命令: ${invalid.map(i => `${i.cmd} (${i.reason})`).join('; ')}`
156
134
  })
157
135
  }
158
136
  }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * SillyHub MCP 客户端 — streamable HTTP 连接(协议 2025-11-25)
3
+ *
4
+ * 设计参考 src/sync.js 的 best-effort 风格:
5
+ * - 网络失败 / 非 2xx / 异常一律 console.warn 不抛错,绝不抛穿到 execute(约束:HTTP 错误保守返回 unavailable/空/false)
6
+ * - 未配置(缺 SILLYHUB_MCP_URL 或 SILLYHUB_MCP_TOKEN)→ 所有方法降级,不发网络
7
+ *
8
+ * 配置来源:环境变量 SILLYHUB_MCP_URL / SILLYHUB_MCP_TOKEN;
9
+ * 构造函数可传 { url, token, timeoutMs } 覆盖,缺省读 env。
10
+ *
11
+ * 端点:POST ${url}/mcp/(尾斜杠必需,MCP streamable HTTP 协议要求),Bearer token 鉴权。
12
+ * 请求体:JSON-RPC 2.0(tools/call 调 tool;tools/list 列 tool schema,task-11 路径A 探测用)。
13
+ * 响应:application/json(直接 JSON-RPC)或 text/event-stream(SSE,取 data: 行拼装后 JSON.parse)。
14
+ * tool 返回值惯例:从 result.content[0].text 再 JSON.parse 得到(tools/list 例外:result 直接是 {tools:[...]})。
15
+ *
16
+ * 铁律:
17
+ * - converge_mission 不封装不调用(D-004 SillySpec 自己 apply)。
18
+ * - worktree_path 仅作 dispatch_worker 入参传递,本文件不碰 DB(不持久化为新列)。
19
+ * - 仅用 Node 原生 fetch(engine>=18),不引入新依赖。
20
+ */
21
+
22
+ const DEFAULT_TIMEOUT_MS = 10_000;
23
+ // MCP streamable HTTP 协议版本(task 指定 2025-11-25)
24
+ const MCP_PROTOCOL_VERSION = '2025-11-25';
25
+
26
+ export class SillyHubMcpClient {
27
+ /**
28
+ * @param {object} [opts]
29
+ * @param {string} [opts.url] - 覆盖 SILLYHUB_MCP_URL;显式传空串视为未配置
30
+ * @param {string} [opts.token] - 覆盖 SILLYHUB_MCP_TOKEN
31
+ * @param {number} [opts.timeoutMs] - 请求超时毫秒,默认 10000
32
+ */
33
+ constructor({ url, token, timeoutMs } = {}) {
34
+ const u = url !== undefined ? url : process.env.SILLYHUB_MCP_URL;
35
+ const t = token !== undefined ? token : process.env.SILLYHUB_MCP_TOKEN;
36
+ // 去掉 url 尾部斜杠,便于后续稳定拼 /mcp/(跨平台:URL 一律用正斜杠,不用 path.join)
37
+ this._url = typeof u === 'string' ? u.replace(/\/+$/, '') : '';
38
+ this._token = typeof t === 'string' ? t : '';
39
+ this._timeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0
40
+ ? timeoutMs
41
+ : DEFAULT_TIMEOUT_MS;
42
+ // 缺 url 或 token → 视为未配置,所有方法降级不发网络
43
+ this._configured = Boolean(this._url && this._token);
44
+ // 端点必须带尾斜杠
45
+ this._endpoint = this._url ? `${this._url}/mcp/` : '';
46
+ this._rpcId = 0;
47
+ }
48
+
49
+ // ── 内部辅助 ──
50
+
51
+ /**
52
+ * 调用 MCP tool(JSON-RPC tools/call)。best-effort:任何失败返回 null 不抛。
53
+ *
54
+ * task-11 抽出共享骨架:本方法现为薄封装 `_sendRpc('tools/call', {name,arguments})`,
55
+ * warn 文案经 label=toolName 与改前逐字一致(零回归现有调用方)。
56
+ * @param {string} toolName
57
+ * @param {object} args - tool 参数(snake_case 对齐 SillyHub schema)
58
+ * @param {object} [opts]
59
+ * @param {boolean} [opts.quiet=false] - 静默失败警告(killLease 等已知 best-effort 路径用)
60
+ * @returns {Promise<object|null>} MCP tool result({content:[{type,text}]}),失败返回 null
61
+ */
62
+ async _callTool(toolName, args = {}, { quiet = false } = {}) {
63
+ return this._sendRpc('tools/call', { name: toolName, arguments: args }, { quiet, label: toolName });
64
+ }
65
+
66
+ /**
67
+ * 发 JSON-RPC 请求(fetch/SSE/鉴权骨架,tools/call 与 tools/list 共用)。best-effort 不抛。
68
+ *
69
+ * task-11 抽出:原 _callTool 的网络骨架;method/params 泛化——tools/call 带 {name,arguments},
70
+ * tools/list 带空 params。warn 文案用 label 作 tag(tools/call 传 toolName 保文案不变,
71
+ * tools/list 传 'tools/list')。
72
+ * @param {string} method - JSON-RPC method('tools/call' | 'tools/list')
73
+ * @param {object} params - JSON-RPC params(tools/call: {name,arguments}; tools/list: {})
74
+ * @param {object} [opts]
75
+ * @param {boolean} [opts.quiet=false] - 静默失败警告
76
+ * @param {string} [opts.label] - warn 文案标签(默认 method;tools/call 传 toolName 保文案)
77
+ * @returns {Promise<object|null>} JSON-RPC result(tools/call: {content}; tools/list: {tools}),失败 null
78
+ */
79
+ async _sendRpc(method, params, { quiet = false, label } = {}) {
80
+ const tag = label || method;
81
+ if (!this._configured) {
82
+ if (!quiet) console.warn(`[sillyhub-mcp] 未配置(缺 SILLYHUB_MCP_URL/TOKEN),${tag} 跳过`);
83
+ return null;
84
+ }
85
+
86
+ this._rpcId += 1;
87
+ const id = this._rpcId;
88
+ const body = {
89
+ jsonrpc: '2.0',
90
+ id,
91
+ method,
92
+ params,
93
+ };
94
+
95
+ const controller = new AbortController();
96
+ const timer = setTimeout(() => controller.abort(), this._timeoutMs);
97
+ let res;
98
+ try {
99
+ res = await fetch(this._endpoint, {
100
+ method: 'POST',
101
+ headers: {
102
+ Authorization: `Bearer ${this._token}`,
103
+ 'Content-Type': 'application/json',
104
+ Accept: 'application/json, text/event-stream',
105
+ 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
106
+ },
107
+ body: JSON.stringify(body),
108
+ signal: controller.signal,
109
+ });
110
+ } catch (err) {
111
+ if (!quiet) {
112
+ if (err && err.name === 'AbortError') {
113
+ console.warn(`[sillyhub-mcp] ${tag} 请求超时 (${this._timeoutMs}ms)`);
114
+ } else {
115
+ const msg = err && err.message ? err.message : String(err);
116
+ console.warn(`[sillyhub-mcp] ${tag} 请求失败: ${msg}`);
117
+ }
118
+ }
119
+ return null;
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+
124
+ if (!res.ok) {
125
+ if (!quiet) {
126
+ let detail = '';
127
+ try { detail = await res.text(); } catch { /* best effort */ }
128
+ console.warn(`[sillyhub-mcp] ${tag} → HTTP ${res.status} ${String(detail).slice(0, 200)}`);
129
+ }
130
+ return null;
131
+ }
132
+
133
+ // 解析响应:JSON 或 SSE(跨平台兼容 \r\n / \n)
134
+ const ct = res.headers.get('content-type') || '';
135
+ let rpc = null;
136
+ try {
137
+ if (ct.includes('text/event-stream')) {
138
+ const text = await res.text();
139
+ rpc = this._parseSseResponse(text, id);
140
+ } else {
141
+ // application/json(或其他):直接 JSON.parse
142
+ rpc = await res.json();
143
+ }
144
+ } catch (err) {
145
+ if (!quiet) {
146
+ const msg = err && err.message ? err.message : String(err);
147
+ console.warn(`[sillyhub-mcp] ${tag} 响应解析失败: ${msg}`);
148
+ }
149
+ return null;
150
+ }
151
+
152
+ if (!rpc) {
153
+ if (!quiet) console.warn(`[sillyhub-mcp] ${tag} 响应无 JSON-RPC 内容`);
154
+ return null;
155
+ }
156
+
157
+ if (rpc.error) {
158
+ if (!quiet) console.warn(`[sillyhub-mcp] ${tag} JSON-RPC error: ${JSON.stringify(rpc.error)}`);
159
+ return null;
160
+ }
161
+
162
+ return rpc.result || null;
163
+ }
164
+
165
+ /**
166
+ * 解析 SSE 响应:取含 JSON-RPC result/error 的事件;优先 id 精确匹配,否则取首个。
167
+ * 事件间空行分隔;事件内 data: 行可多行,拼装时用 \n 连接。
168
+ */
169
+ _parseSseResponse(text, expectedId) {
170
+ const events = String(text).split(/\r?\n\r?\n/);
171
+ let fallback = null;
172
+ for (const evt of events) {
173
+ const dataLines = [];
174
+ for (const line of evt.split(/\r?\n/)) {
175
+ if (line.startsWith('data:')) {
176
+ // 去掉 "data:" 前缀与一个可选前导空格
177
+ dataLines.push(line.slice(5).replace(/^ /, ''));
178
+ }
179
+ }
180
+ if (dataLines.length === 0) continue;
181
+ const data = dataLines.join('\n');
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(data);
185
+ } catch {
186
+ continue; // 非 JSON data(心跳/注释等),跳过
187
+ }
188
+ if (!parsed || typeof parsed !== 'object') continue;
189
+ if (parsed.result !== undefined || parsed.error !== undefined) {
190
+ if (expectedId !== undefined && parsed.id === expectedId) return parsed;
191
+ if (!fallback) fallback = parsed;
192
+ }
193
+ }
194
+ return fallback;
195
+ }
196
+
197
+ /**
198
+ * 从 MCP tool result 提取 content[0].text 并 JSON.parse 为 tool 返回值(MCP tool 结果惯例)。
199
+ * @returns {any|null} 解析后的 tool 返回值;非 JSON 字符串则原样返回;缺失返回 null
200
+ */
201
+ _parseToolReturnValue(result) {
202
+ if (!result || !Array.isArray(result.content) || result.content.length === 0) return null;
203
+ const first = result.content[0];
204
+ if (!first || typeof first.text !== 'string') return null;
205
+ const text = first.text;
206
+ try {
207
+ return JSON.parse(text);
208
+ } catch {
209
+ return text; // tool 返回非 JSON 字符串,原样返回
210
+ }
211
+ }
212
+
213
+ // ── 对外方法(均 async,best-effort 不抛) ──
214
+
215
+ /**
216
+ * 探测 daemon 连通性 + token 有效性(调 list_agent_profiles tool)。
217
+ * 未配置 / HTTP 异常 / 非 2xx / JSON-RPC error → 返回 false(不阻塞调用方)。
218
+ * @returns {Promise<boolean>} 成功且 tool 返回非空 → true,否则 false
219
+ */
220
+ async probeDaemon() {
221
+ if (!this._configured) return false;
222
+ const result = await this._callTool('list_agent_profiles', {});
223
+ return result !== null;
224
+ }
225
+
226
+ /**
227
+ * 列出 daemon 暴露的 MCP tools(JSON-RPC `tools/list`,task-11 路径A 探测用)。
228
+ *
229
+ * 与 tools/call 的差异:method='tools/list',无 name/arguments;响应 result 是
230
+ * `{tools: [{name, description, inputSchema}, ...]}`(MCP 标准)。本方法返回 tools 数组
231
+ * (优先 result.tools,兼容 daemon 直接返回数组),调用方(probe.js 路径A 探测)据此查
232
+ * dispatch_worker.inputSchema.properties 是否含 worktree_path + worker_prompt。
233
+ *
234
+ * best-effort:未配置 / HTTP 异常 / 非 2xx / JSON-RPC error / 解析失败 → 返回 null 不抛
235
+ * (R-04 保守,探测失败由调用方降级 false)。默认 quiet=true(探测路径不刷失败噪音,
236
+ * 区别于业务 tool 调用默认 quiet=false)。
237
+ * @param {object} [opts]
238
+ * @param {boolean} [opts.quiet=true] - 静默失败警告(探测路径默认静默)
239
+ * @returns {Promise<Array<{name:string, inputSchema?:object}>|null>} tools 数组;失败/未配置 null
240
+ */
241
+ async listTools({ quiet = true } = {}) {
242
+ if (!this._configured) return null;
243
+ const result = await this._sendRpc('tools/list', {}, { quiet, label: 'tools/list' });
244
+ if (!result) return null;
245
+ if (Array.isArray(result)) return result; // daemon 直接返回数组(兼容)
246
+ if (Array.isArray(result.tools)) return result.tools; // 标准 MCP {tools:[...]}
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * best-effort 从 daemon 拿 workspace root_path(task-12 路径A 越界校验用)。
252
+ *
253
+ * 复用 tools/list RPC 机制(与 listTools 同 method,复用 _sendRpc 骨架,task-12 "复用 task-11
254
+ * listTools"):defensively 读 result.root_path(若 daemon 在 tools/list 响应顶层暴露)。
255
+ * 当前 SillyHub gateway 的 tools/list 仅返 `{tools:[...]}` 不含 root_path → 实际返回 null
256
+ * (probe 跳过越界校验,best-effort 语义)。**不臆造 daemon 接口**(无专用 get_workspace_root
257
+ * tool,见 task-12 constraints / spike-01 结论);后续 daemon 若在 tools/list 顶层暴露 root_path,
258
+ * 本方法自动适配,probe 无需改。
259
+ *
260
+ * best-effort:未配置 / 异常 / 无 root_path 字段 → 返回 null 不抛。
261
+ * @param {object} [opts]
262
+ * @param {boolean} [opts.quiet=true] - 静默失败警告
263
+ * @returns {Promise<string|null>} root_path 绝对路径;拿不到 null
264
+ */
265
+ async getRootPath({ quiet = true } = {}) {
266
+ if (!this._configured) return null;
267
+ const result = await this._sendRpc('tools/list', {}, { quiet, label: 'tools/list' });
268
+ if (!result || typeof result !== 'object') return null;
269
+ const rp = result.root_path;
270
+ return typeof rp === 'string' && rp.length > 0 ? rp : null;
271
+ }
272
+
273
+ /**
274
+ * 创建 mission(调 create_mission tool)。
275
+ * @param {object} p
276
+ * @param {string} p.objective
277
+ * @param {string} p.changeId
278
+ * @param {number} [p.budgetUsd]
279
+ * @param {string} [p.orchestrationMode] - 'team'(默认,不传走原逻辑零回归,FR-05) |
280
+ * 'external'(路径A,SillySpec 外部调度:跳 orchestrator spawn + converge 跳 merge,FR-08/D-007)。
281
+ * 传入时 args.orchestration_mode = mode;不传/Null 不加字段(team 默认,零回归)。
282
+ * @returns {Promise<{missionId: string|null}>} 未配置/失败 → { missionId: null }
283
+ */
284
+ async createMission({ objective, changeId, budgetUsd, orchestrationMode } = {}) {
285
+ if (!this._configured) return { missionId: null };
286
+ const args = {
287
+ objective,
288
+ change_id: changeId,
289
+ };
290
+ if (budgetUsd !== undefined && budgetUsd !== null) args.budget_usd = budgetUsd;
291
+ // task-12:路径A 传 'external'(跳 orchestrator spawn,FR-08);不传 → 不加字段,daemon 默认 team 零回归
292
+ if (orchestrationMode !== undefined && orchestrationMode !== null) {
293
+ args.orchestration_mode = orchestrationMode;
294
+ }
295
+ const result = await this._callTool('create_mission', args);
296
+ if (result === null) return { missionId: null };
297
+ const value = this._parseToolReturnValue(result);
298
+ const missionId = value && typeof value === 'object'
299
+ ? (value.mission_id ?? value.missionId ?? null)
300
+ : null;
301
+ return { missionId };
302
+ }
303
+
304
+ /**
305
+ * 派发 worker(调 dispatch_worker tool,路径A 入参含 worktree_path/branch/worker_prompt)。
306
+ * worktree_path 仅作 tool 入参传递,不持久化为 DB 列。
307
+ * @param {object} p
308
+ * @param {string} p.missionId
309
+ * @param {string} p.objective
310
+ * @param {string} [p.worktreePath]
311
+ * @param {string} [p.branch]
312
+ * @param {boolean} [p.readOnly]
313
+ * @param {string} [p.model]
314
+ * @param {string} [p.agentProfileId]
315
+ * @param {string} [p.workerPrompt]
316
+ * @returns {Promise<{workerId: string|null, status: string}>} 未配置/失败 → { workerId: null, status: 'unavailable' }
317
+ */
318
+ async dispatchWorker({
319
+ missionId, objective, worktreePath, branch, readOnly, model, agentProfileId, workerPrompt,
320
+ } = {}) {
321
+ if (!this._configured) return { workerId: null, status: 'unavailable' };
322
+ const args = {
323
+ mission_id: missionId,
324
+ objective,
325
+ };
326
+ if (readOnly !== undefined) args.read_only = readOnly;
327
+ if (worktreePath !== undefined && worktreePath !== null) args.worktree_path = worktreePath;
328
+ // task-12 / D-009:branch 字段名对齐跨仓契约(design §7.3,round-1 worktree_branch 已统一为 branch)
329
+ if (branch !== undefined && branch !== null) args.branch = branch;
330
+ if (model !== undefined && model !== null) args.model = model;
331
+ if (agentProfileId !== undefined && agentProfileId !== null) args.agent_profile_id = agentProfileId;
332
+ if (workerPrompt !== undefined && workerPrompt !== null) args.worker_prompt = workerPrompt;
333
+ const result = await this._callTool('dispatch_worker', args);
334
+ if (result === null) return { workerId: null, status: 'unavailable' };
335
+ const value = this._parseToolReturnValue(result);
336
+ const workerId = value && typeof value === 'object'
337
+ ? (value.worker_id ?? value.workerId ?? null)
338
+ : null;
339
+ const status = value && typeof value === 'object'
340
+ ? (value.status ?? 'unknown')
341
+ : 'unknown';
342
+ return { workerId, status };
343
+ }
344
+
345
+ /**
346
+ * 列出 mission 下的 worker(调 list_workers tool)。
347
+ * @param {string} missionId
348
+ * @returns {Promise<Array>} 未配置/失败返回 [];兼容 tool 返回数组或 { workers: [...] }
349
+ */
350
+ async listWorkers(missionId) {
351
+ if (!this._configured) return [];
352
+ const result = await this._callTool('list_workers', { mission_id: missionId });
353
+ if (result === null) return [];
354
+ const value = this._parseToolReturnValue(result);
355
+ if (Array.isArray(value)) return value;
356
+ if (value && Array.isArray(value.workers)) return value.workers;
357
+ return [];
358
+ }
359
+
360
+ /**
361
+ * 终止 worker lease 防双写(UB-6 超时 fallback 用)。
362
+ *
363
+ * 注:SillyHub 当前 8 个 tool 无显式 kill,路径A 未落地;best-effort 实现——
364
+ * 尝试 report_progress 带 kill 标记投递终止请求;tool 不存在/不支持/HTTP 异常时静默返回不抛。
365
+ * 由于无专用 kill tool,lease 实际终止不保证,结果保守报 killed=false
366
+ * (路径A 落地专用 kill tool 后再升级为 killed=true 语义)。
367
+ * @param {string} workerId
368
+ * @returns {Promise<{killed: boolean, reason?: string}>}
369
+ */
370
+ async killLease(workerId) {
371
+ if (!this._configured) {
372
+ return { killed: false, reason: '未配置 SillyHub MCP(路径A 未落地/kill tool 未支持)' };
373
+ }
374
+ // best-effort:quiet 抑制已知未落地路径的失败噪音(静默返回不抛)
375
+ const result = await this._callTool('report_progress', {
376
+ worker_id: workerId,
377
+ kill: true,
378
+ marker: 'sillyspec-kill-lease',
379
+ }, { quiet: true });
380
+ if (result === null) {
381
+ return { killed: false, reason: '路径A 未落地/kill tool 未支持(report_progress kill 标记未生效或被拒)' };
382
+ }
383
+ // report_progress 调用成功 ≠ lease 已终止(其非专用 kill tool);保守报 killed=false
384
+ return {
385
+ killed: false,
386
+ reason: '路径A 未落地/kill tool 未支持(kill 标记已投递,lease 实际终止需 daemon 侧专用 kill tool)',
387
+ };
388
+ }
389
+ }