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.
- package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
- package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/change-list.js +11 -8
- package/src/change-risk-profile.js +22 -0
- package/src/db.js +160 -159
- package/src/dispatch/backends/local-agent.js +152 -0
- package/src/dispatch/backends/sillyhub-mcp.js +242 -0
- package/src/dispatch/probe.js +208 -0
- package/src/dispatch/strategy.js +121 -0
- package/src/doctor-diagnostics.js +19 -25
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +66 -0
- package/src/hooks/worktree-guard.js +64 -55
- package/src/index.js +176 -34
- package/src/init.js +7 -14
- package/src/machine-interface.js +54 -19
- package/src/migrate.js +14 -4
- package/src/modules.js +16 -5
- package/src/progress/change-registry.js +72 -70
- package/src/progress/consistency-doctor.js +71 -14
- package/src/progress/shared.js +6 -3
- package/src/progress/stage-machine.js +61 -65
- package/src/progress/step-store.js +62 -67
- package/src/progress.js +155 -233
- package/src/quicklog.js +13 -5
- package/src/run/command.js +65 -37
- package/src/run/complete-handlers.js +1005 -889
- package/src/run/complete.js +115 -208
- package/src/run/concurrent-detect.js +132 -0
- package/src/run/gates.js +289 -17
- package/src/run/prompt.js +22 -68
- package/src/run/quick-audit.js +10 -9
- package/src/run/shared.js +648 -600
- package/src/run/stage.js +43 -24
- package/src/scan-postcheck.js +9 -31
- package/src/sillyhub-mcp/client.js +389 -0
- package/src/spec-dir-typo.js +71 -0
- package/src/stage-contract-engine.js +22 -1
- package/src/stage-contract-spec.js +11 -4
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +13 -9
- package/src/stages/archive.js +4 -6
- package/src/stages/brainstorm-auto.js +26 -20
- package/src/stages/brainstorm.js +29 -5
- package/src/stages/cmd-existence.js +145 -0
- package/src/stages/doctor.js +9 -6
- package/src/stages/execute.js +61 -20
- package/src/stages/plan-postcheck.js +152 -0
- package/src/stages/plan.js +16 -31
- package/src/stages/quick.js +1 -1
- package/src/stages/scan.js +9 -3
- package/src/stages/status.js +0 -1
- package/src/stages/verify.js +12 -7
- package/src/sync.js +4 -4
- package/src/task-review.js +30 -1
- package/src/verify-postcheck.js +933 -862
- package/src/version.js +19 -0
- package/src/worktree-apply.js +85 -68
- package/src/worktree-deps.js +89 -5
- package/src/worktree.js +213 -111
- package/templates/prompts/taskcard-rules.md +27 -0
- package/templates/prompts/verify-probes.md +3 -3
- package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
const after =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 不供给)时,校验
|
|
394
|
-
*
|
|
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,
|
|
421
|
+
const { provisionDeps, checkDepsFreshness } = await import('../worktree-deps.js')
|
|
399
422
|
const wtPath = worktreeMeta.worktreePath
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
const
|
|
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 {
|
package/src/scan-postcheck.js
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
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 引用不存在的命令: ${
|
|
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
|
+
}
|