sillyspec 3.26.6 → 3.26.8
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-doctor/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.husky/pre-push +9 -2
- package/bin/sillyspec.js +1 -1
- package/package.json +3 -3
- package/packages/dashboard/server/index.js +8 -1
- package/src/config-schema.js +305 -287
- package/src/docs-check.js +333 -0
- package/src/docs-debt.js +226 -0
- package/src/docs-gate.js +106 -0
- package/src/doctor-diagnostics.js +23 -2
- package/src/index.js +1880 -1750
- package/src/init.js +81 -14
- package/src/modules.js +490 -486
- package/src/progress.js +37 -13
- package/src/quicklog.js +19 -4
- package/src/run/command.js +54 -24
- package/src/run/complete-handlers.js +1254 -1128
- package/src/run/complete.js +8 -34
- package/src/run/gates.js +32 -3
- package/src/run/prompt.js +713 -633
- package/src/run/quick-audit.js +137 -123
- package/src/run/shared.js +1109 -929
- package/src/run/stage.js +8 -3
- package/src/scan-staleness.js +90 -0
- package/src/setup.js +27 -9
- package/src/sillyhub-mcp/client.js +149 -14
- package/src/stage-contract-spec.js +15 -0
- package/src/stage-contract.js +8 -5
- package/src/stages/brainstorm-auto.js +0 -1
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/execute.js +985 -967
- package/src/stages/plan-postcheck.js +93 -36
- package/src/stages/verify.js +1 -1
- package/src/sync.js +60 -32
- package/src/task-review.js +38 -6
- package/src/verify-postcheck.js +22 -5
- package/src/worktree-apply.js +6 -3
- package/src/worktree-deps.js +5 -3
- package/src/worktree.js +4 -2
package/src/run/stage.js
CHANGED
|
@@ -89,15 +89,20 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
89
89
|
// ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
|
|
90
90
|
let currentExecuteRunId = null
|
|
91
91
|
if (stageName === 'execute') {
|
|
92
|
-
const { generateExecuteRunId } = await import('../task-review.js')
|
|
92
|
+
const { generateExecuteRunId, isValidExecuteRunId } = await import('../task-review.js')
|
|
93
93
|
const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
94
94
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
|
|
95
95
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
96
96
|
mkdirSync(runtimeRoot, { recursive: true })
|
|
97
|
-
//
|
|
97
|
+
// 优先读取已有的变更专属标记文件(agent 可写内容,格式校验防注入/穿越,非法视为缺失重生成)
|
|
98
98
|
try {
|
|
99
99
|
if (existsSync(runIdFile)) {
|
|
100
|
-
|
|
100
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
101
|
+
if (c && !isValidExecuteRunId(c)) {
|
|
102
|
+
console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),重新生成`)
|
|
103
|
+
} else {
|
|
104
|
+
currentExecuteRunId = c
|
|
105
|
+
}
|
|
101
106
|
}
|
|
102
107
|
} catch {}
|
|
103
108
|
if (!currentExecuteRunId) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scan-staleness — scan 文档漂移检测(债单 D-7 方案 A,change: 2026-08-15 ql-20260815-013)
|
|
3
|
+
*
|
|
4
|
+
* 原则(doc-consistency-debt 第六节用户裁决):CLI 算事实注入——git 算出 scan 文档落后程度,
|
|
5
|
+
* 注入给 agent 的是一行结论不是一段劝说指令。brainstorm「加载项目上下文」读 scan 文档前
|
|
6
|
+
* 就知道"我读的架构描述可能过期",不再盲信。
|
|
7
|
+
*
|
|
8
|
+
* 判定口径:scan 文档 frontmatter 的 source_commit(modules.js 写入、worktree-guard 覆盖
|
|
9
|
+
* 保护同源字段)vs 当前 HEAD——`git rev-list --count <source_commit>..HEAD` + 提交时间差。
|
|
10
|
+
* 纯读操作(git 只读 + frontmatter 解析),任何失败降级 null(advisory 信号不阻断流程)。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { safeGit } from './git-helper.js'
|
|
15
|
+
|
|
16
|
+
/** 漂移阈值(commit 数 / 天数,超过任一即 stale;advisory 性质允许保守默认) */
|
|
17
|
+
export const STALENESS_THRESHOLDS = { commits: 50, days: 14 }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 纯函数:从 scan 文档内容解析 frontmatter 的 source_commit。
|
|
21
|
+
* @param {string} content 文档全文
|
|
22
|
+
* @returns {string|null} commit hex(短/长均可);无字段/无 frontmatter → null
|
|
23
|
+
*/
|
|
24
|
+
export function parseSourceCommit(content) {
|
|
25
|
+
if (!content || typeof content !== 'string') return null
|
|
26
|
+
// \r? 容错 CRLF($ 前的 \r 会致 LF 正则失配)
|
|
27
|
+
const m = content.match(/^---\r?\n[\s\S]*?^source_commit:\s*([0-9a-f]{7,40})\s*\r?$/m)
|
|
28
|
+
return m ? m[1] : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 计算单个 project 的 scan 文档漂移事实。
|
|
33
|
+
* @param {{ projectRoot: string, specBase: string, projectName: string, thresholds?: { commits: number, days: number } }} opts
|
|
34
|
+
* @returns {{ status: 'fresh'|'stale'|'unknown', behindCommits: number|null, daysSinceScan: number|null, sourceCommit: string|null, message: string }|null}
|
|
35
|
+
* null = 无 scan 文档目录(绿地项目等),调用方直接跳过;unknown = 有文档但算不出(无
|
|
36
|
+
* source_commit 字段 / git 失败),message 说明降级原因。
|
|
37
|
+
*/
|
|
38
|
+
export function computeScanStaleness(opts) {
|
|
39
|
+
const { projectRoot, specBase, projectName, thresholds = STALENESS_THRESHOLDS } = opts || {}
|
|
40
|
+
if (!projectRoot || !specBase || !projectName) return null
|
|
41
|
+
const scanDir = join(specBase, 'docs', projectName, 'scan')
|
|
42
|
+
if (!existsSync(scanDir)) return null
|
|
43
|
+
|
|
44
|
+
// 任一 scan 文档带 source_commit 即可代表整批(modules.js 同批写入同值)
|
|
45
|
+
let sourceCommit = null
|
|
46
|
+
let checked = 0
|
|
47
|
+
for (const f of readdirSync(scanDir)) {
|
|
48
|
+
if (!f.endsWith('.md')) continue
|
|
49
|
+
checked++
|
|
50
|
+
const c = parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))
|
|
51
|
+
if (c) { sourceCommit = c; break }
|
|
52
|
+
}
|
|
53
|
+
if (checked === 0) return null
|
|
54
|
+
if (!sourceCommit) {
|
|
55
|
+
return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: null,
|
|
56
|
+
message: 'scan 文档无 source_commit 字段(旧版生成),无法判漂移——如架构已大改建议重跑 sillyspec run scan --standard' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const head = safeGit(projectRoot, ['rev-parse', 'HEAD'])
|
|
60
|
+
if (head.error || !head.value) {
|
|
61
|
+
return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit,
|
|
62
|
+
message: `git 读取失败(${head.error || '无 HEAD'}),跳过漂移判定` }
|
|
63
|
+
}
|
|
64
|
+
const isAncestor = safeGit(projectRoot, ['merge-base', '--is-ancestor', sourceCommit, head.value.trim()])
|
|
65
|
+
if (isAncestor.error) {
|
|
66
|
+
return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit,
|
|
67
|
+
message: `source_commit ${sourceCommit.slice(0, 7)} 不在当前分支历史(分支切换/rebase),跳过漂移判定` }
|
|
68
|
+
}
|
|
69
|
+
const countRes = safeGit(projectRoot, ['rev-list', '--count', `${sourceCommit}..HEAD`])
|
|
70
|
+
const behindCommits = countRes.error ? null : parseInt(countRes.value, 10)
|
|
71
|
+
if (behindCommits === null || Number.isNaN(behindCommits)) {
|
|
72
|
+
return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit,
|
|
73
|
+
message: 'rev-list 计数失败,跳过漂移判定' }
|
|
74
|
+
}
|
|
75
|
+
const dateRes = safeGit(projectRoot, ['log', '-1', '--format=%cI', sourceCommit])
|
|
76
|
+
const daysSinceScan = dateRes.error ? null
|
|
77
|
+
: Math.floor((Date.now() - new Date(dateRes.value.trim()).getTime()) / 86400000)
|
|
78
|
+
|
|
79
|
+
const stale = behindCommits >= thresholds.commits || (daysSinceScan !== null && daysSinceScan >= thresholds.days)
|
|
80
|
+
if (!stale) {
|
|
81
|
+
return { status: 'fresh', behindCommits, daysSinceScan, sourceCommit,
|
|
82
|
+
message: `scan 文档新鲜(落后 ${behindCommits} commit / ${daysSinceScan ?? '?'} 天)` }
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
status: 'stale', behindCommits, daysSinceScan, sourceCommit,
|
|
86
|
+
message: `⚠️ scan 文档过期:停在 ${sourceCommit.slice(0, 7)}(${daysSinceScan ?? '?'} 天前),期间源码已推进 ${behindCommits} commit。` +
|
|
87
|
+
`本次加载的架构/结构描述可能失真——涉及结构变更的 design 不要盲信 scan 文档,以实读代码为准;` +
|
|
88
|
+
`如需刷新基线跑 sillyspec run scan --standard --force-rescan`,
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
|
-
import { execSync } from 'child_process';
|
|
3
|
+
import { execSync, execFileSync } from 'child_process';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import ora from 'ora';
|
|
6
6
|
import { checkbox, input } from '@inquirer/prompts';
|
|
@@ -13,7 +13,9 @@ const MCP_TOOLS = [
|
|
|
13
13
|
name: 'Context7',
|
|
14
14
|
description: '查询最新库文档和 API 参考',
|
|
15
15
|
command: 'npx',
|
|
16
|
-
|
|
16
|
+
// 版本锁定(sec-c):MCP server 常驻且有系统访问能力,@latest = trust-on-future-publish,
|
|
17
|
+
// 上游被攻破会直接进用户 AI 工具执行环境。升级走 setup 重跑。
|
|
18
|
+
args: ['-y', '@upstash/context7-mcp@4.0.2'],
|
|
17
19
|
url: 'https://github.com/upstash/context7-mcp',
|
|
18
20
|
},
|
|
19
21
|
{
|
|
@@ -21,7 +23,7 @@ const MCP_TOOLS = [
|
|
|
21
23
|
name: 'Chrome DevTools MCP',
|
|
22
24
|
description: '浏览器自动化,支持 E2E 测试(需 Chrome 已运行)',
|
|
23
25
|
command: 'npx',
|
|
24
|
-
args: ['chrome-devtools-mcp@
|
|
26
|
+
args: ['chrome-devtools-mcp@1.7.0'],
|
|
25
27
|
url: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
|
|
26
28
|
},
|
|
27
29
|
{
|
|
@@ -29,7 +31,7 @@ const MCP_TOOLS = [
|
|
|
29
31
|
name: 'Agent Browser (Vercel)',
|
|
30
32
|
description: 'Rust 原生浏览器 CLI,token 消耗极低,50+ 命令覆盖导航/表单/截图/网络',
|
|
31
33
|
command: 'npx',
|
|
32
|
-
args: ['
|
|
34
|
+
args: ['agent-browser@0.34.0'],
|
|
33
35
|
url: 'https://github.com/vercel-labs/agent-browser',
|
|
34
36
|
},
|
|
35
37
|
{
|
|
@@ -37,7 +39,7 @@ const MCP_TOOLS = [
|
|
|
37
39
|
name: 'PinchTab',
|
|
38
40
|
description: '12MB Go 二进制,零依赖,accessibility tree 极省 token,有 MCP 支持',
|
|
39
41
|
command: 'npx',
|
|
40
|
-
args: ['pinchtab-mcp@
|
|
42
|
+
args: ['pinchtab-mcp@1.4.1'],
|
|
41
43
|
url: 'https://github.com/pinchtab/pinchtab',
|
|
42
44
|
},
|
|
43
45
|
];
|
|
@@ -50,7 +52,7 @@ const DB_MCP_TOOLS = [
|
|
|
50
52
|
name: 'PostgreSQL',
|
|
51
53
|
description: '只读访问 PostgreSQL 数据库',
|
|
52
54
|
command: 'npx',
|
|
53
|
-
args: ['-y', '@modelcontextprotocol/server-postgres'],
|
|
55
|
+
args: ['-y', '@modelcontextprotocol/server-postgres@0.6.2'],
|
|
54
56
|
envTemplate: { POSTGRES_CONNECTION_STRING: 'postgresql://user:password@localhost:5432/dbname' },
|
|
55
57
|
url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/postgres',
|
|
56
58
|
},
|
|
@@ -59,7 +61,7 @@ const DB_MCP_TOOLS = [
|
|
|
59
61
|
name: 'SQLite',
|
|
60
62
|
description: '访问 SQLite 数据库文件',
|
|
61
63
|
command: 'npx',
|
|
62
|
-
args: ['-y', '
|
|
64
|
+
args: ['-y', 'mcp-server-sqlite@0.0.2'],
|
|
63
65
|
envTemplate: { SQLITE_DB_PATH: './data.db' },
|
|
64
66
|
url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite',
|
|
65
67
|
},
|
|
@@ -68,7 +70,8 @@ const DB_MCP_TOOLS = [
|
|
|
68
70
|
name: 'MySQL / MariaDB',
|
|
69
71
|
description: '访问 MySQL / MariaDB 数据库',
|
|
70
72
|
command: 'npx',
|
|
71
|
-
|
|
73
|
+
// 原 @nicobailon/mysql-mcp-server 已 404(包下架),换存在的 mysql-mcp-server(dpflucas,read-only)
|
|
74
|
+
args: ['-y', 'mysql-mcp-server@0.1.3'],
|
|
72
75
|
envTemplate: { MYSQL_HOST: 'localhost', MYSQL_PORT: '3306', MYSQL_USER: 'root', MYSQL_PASSWORD: '', MYSQL_DATABASE: '' },
|
|
73
76
|
url: 'https://github.com/modelcontextprotocol/servers',
|
|
74
77
|
},
|
|
@@ -77,7 +80,7 @@ const DB_MCP_TOOLS = [
|
|
|
77
80
|
name: 'Redis',
|
|
78
81
|
description: '访问 Redis 数据库,查看缓存和键值数据',
|
|
79
82
|
command: 'npx',
|
|
80
|
-
args: ['-y', '@modelcontextprotocol/server-redis'],
|
|
83
|
+
args: ['-y', '@modelcontextprotocol/server-redis@2025.4.25'],
|
|
81
84
|
envTemplate: { REDIS_URL: 'redis://localhost:6379' },
|
|
82
85
|
url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/redis',
|
|
83
86
|
},
|
|
@@ -353,6 +356,21 @@ export async function cmdSetup(dir, options = {}) {
|
|
|
353
356
|
}
|
|
354
357
|
writeMcpConfig(dir, path, config);
|
|
355
358
|
spinner.succeed(`${tool} MCP 完成 (${allMcp.length} 个工具)`);
|
|
359
|
+
// 凭据提交风险提示(sec-c):.claude/mcp.json / .cursor/mcp.json 是项目共享文件、通常随 git
|
|
360
|
+
// 提交,而 DB MCP 的 env 含明文连接串/密码。检测 git 跟踪状态,已跟踪/未忽略时显式警告。
|
|
361
|
+
if (selectedDb.length > 0) {
|
|
362
|
+
try {
|
|
363
|
+
execFileSync('git', ['-C', dir, 'ls-files', '--error-unmatch', path], { stdio: 'pipe' });
|
|
364
|
+
console.warn(chalk.yellow(` ⚠️ ${path} 已被 git 跟踪——其中数据库连接串/密码会随提交进入仓库,建议加入 .gitignore 或改用环境变量`));
|
|
365
|
+
} catch {
|
|
366
|
+
// 未被跟踪:再查是否被 ignore;都不满足(未忽略未跟踪)时提示防止将来误提交
|
|
367
|
+
try {
|
|
368
|
+
execFileSync('git', ['-C', dir, 'check-ignore', '-q', path], { stdio: 'pipe' });
|
|
369
|
+
} catch {
|
|
370
|
+
console.warn(chalk.yellow(` ⚠️ ${path} 含数据库连接串/密码且未被 .gitignore 忽略——提交前请先加入 .gitignore 或改用环境变量`));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
356
374
|
}
|
|
357
375
|
}
|
|
358
376
|
|
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { readMcpConfig } from './config.js';
|
|
23
|
+
import { getVersion } from '../version.js';
|
|
23
24
|
|
|
24
25
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
25
26
|
// MCP streamable HTTP 协议版本(task 指定 2025-11-25)
|
|
26
27
|
const MCP_PROTOCOL_VERSION = '2025-11-25';
|
|
28
|
+
// 单次 RPC 返回哨兵:server 回 400 Missing session(session 未建立/过期)→ _sendRpc 重连重试
|
|
29
|
+
const SESSION_EXPIRED = Symbol('SESSION_EXPIRED');
|
|
27
30
|
|
|
28
31
|
export class SillyHubMcpClient {
|
|
29
32
|
/**
|
|
@@ -38,9 +41,15 @@ export class SillyHubMcpClient {
|
|
|
38
41
|
const cfg = readMcpConfig(workCwd);
|
|
39
42
|
const u = url !== undefined ? url : (cfg?.url ?? '');
|
|
40
43
|
const t = token !== undefined ? token : (cfg?.token ?? '');
|
|
41
|
-
//
|
|
44
|
+
// 去 url 尾部斜杠,便于后续稳定拼 /mcp/(跨平台:URL 一律用正斜杠,不用 path.join)
|
|
42
45
|
this._url = typeof u === 'string' ? u.replace(/\/+$/, '') : '';
|
|
43
46
|
this._token = typeof t === 'string' ? t : '';
|
|
47
|
+
// 安全提示(sec-e 缓解):url 来自 local.yaml(agent 可写),非 https 时 Bearer token 明文上线。
|
|
48
|
+
// 一次性 warn(构造时)不刷屏;本机 http://localhost 调试场景 warn 可接受。
|
|
49
|
+
if (this._url && !/^https:\/\//i.test(this._url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(this._url)) {
|
|
50
|
+
console.warn(`[sillyhub-mcp] mcp.url 非 https(${this._url.slice(0, 60)}),Bearer token 将明文传输——请确认为受控环境`);
|
|
51
|
+
}
|
|
52
|
+
this._token = typeof t === 'string' ? t : '';
|
|
44
53
|
this._timeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0
|
|
45
54
|
? timeoutMs
|
|
46
55
|
: DEFAULT_TIMEOUT_MS;
|
|
@@ -49,6 +58,9 @@ export class SillyHubMcpClient {
|
|
|
49
58
|
// 端点必须带尾斜杠
|
|
50
59
|
this._endpoint = this._url ? `${this._url}/mcp/` : '';
|
|
51
60
|
this._rpcId = 0;
|
|
61
|
+
// MCP streamable HTTP session 状态:惰性 initialize 后才有(见 _ensureSession)
|
|
62
|
+
this._sessionId = null;
|
|
63
|
+
this._sessionPromise = null;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
// ── 内部辅助 ──
|
|
@@ -69,7 +81,15 @@ export class SillyHubMcpClient {
|
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
/**
|
|
72
|
-
* 发 JSON-RPC 请求(fetch/SSE
|
|
84
|
+
* 发 JSON-RPC 请求(fetch/SSE/鉴权/session 骨架,tools/call 与 tools/list 共用)。best-effort 不抛。
|
|
85
|
+
*
|
|
86
|
+
* MCP streamable HTTP(2025-11-25)要求先 initialize 拿 session,后续请求带
|
|
87
|
+
* Mcp-Session-Id header,否则 server 回 -32600 Missing session ID(2026-08-14 实测:
|
|
88
|
+
* SillyHub FastMCP v1.29 streamable_http_app 强制 session;此前本 client 直接发
|
|
89
|
+
* tools/call 无 session → 一律 400,probe 误判 daemon-unreachable)。本方法:
|
|
90
|
+
* 1. 惰性 _ensureSession(并发安全,仅 initialize 一次);
|
|
91
|
+
* 2. _rpcOnce 发单次请求;
|
|
92
|
+
* 3. 遇 session 过期(400 Missing session)→ 重置重连重试一次。
|
|
73
93
|
*
|
|
74
94
|
* task-11 抽出:原 _callTool 的网络骨架;method/params 泛化——tools/call 带 {name,arguments},
|
|
75
95
|
* tools/list 带空 params。warn 文案用 label 作 tag(tools/call 传 toolName 保文案不变,
|
|
@@ -88,14 +108,43 @@ export class SillyHubMcpClient {
|
|
|
88
108
|
return null;
|
|
89
109
|
}
|
|
90
110
|
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
// 惰性 initialize + 过期重连(最多 2 次:首次 + 重试)
|
|
112
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
113
|
+
await this._ensureSession(quiet);
|
|
114
|
+
if (!this._sessionId) {
|
|
115
|
+
// initialize 失败(网络/非 2xx/无 session)→ best-effort 降级,不再发业务请求
|
|
116
|
+
if (!quiet) console.warn(`[sillyhub-mcp] ${tag} initialize 失败,跳过`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
this._rpcId += 1;
|
|
120
|
+
const result = await this._rpcOnce(method, params, { quiet, tag, id: this._rpcId });
|
|
121
|
+
if (result !== SESSION_EXPIRED) return result;
|
|
122
|
+
// session 过期(400 Missing session)→ 重置重连重试一次
|
|
123
|
+
if (!quiet) console.warn(`[sillyhub-mcp] ${tag} session 过期,重连重试`);
|
|
124
|
+
this._sessionId = null;
|
|
125
|
+
this._sessionPromise = null;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 发单次 JSON-RPC 请求(不管理 session)。返回 rpc.result;失败返回 null;
|
|
132
|
+
* 400 Missing session ID → 返回 SESSION_EXPIRED 哨兵(调用方 _sendRpc 据此重连重试)。
|
|
133
|
+
*/
|
|
134
|
+
async _rpcOnce(method, params, { quiet, tag, id }) {
|
|
93
135
|
const body = {
|
|
94
136
|
jsonrpc: '2.0',
|
|
95
137
|
id,
|
|
96
138
|
method,
|
|
97
139
|
params,
|
|
98
140
|
};
|
|
141
|
+
const headers = {
|
|
142
|
+
Authorization: `Bearer ${this._token}`,
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
Accept: 'application/json, text/event-stream',
|
|
145
|
+
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
146
|
+
};
|
|
147
|
+
if (this._sessionId) headers['Mcp-Session-Id'] = this._sessionId;
|
|
99
148
|
|
|
100
149
|
const controller = new AbortController();
|
|
101
150
|
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
@@ -103,12 +152,7 @@ export class SillyHubMcpClient {
|
|
|
103
152
|
try {
|
|
104
153
|
res = await fetch(this._endpoint, {
|
|
105
154
|
method: 'POST',
|
|
106
|
-
headers
|
|
107
|
-
Authorization: `Bearer ${this._token}`,
|
|
108
|
-
'Content-Type': 'application/json',
|
|
109
|
-
Accept: 'application/json, text/event-stream',
|
|
110
|
-
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
111
|
-
},
|
|
155
|
+
headers,
|
|
112
156
|
body: JSON.stringify(body),
|
|
113
157
|
signal: controller.signal,
|
|
114
158
|
});
|
|
@@ -127,11 +171,13 @@ export class SillyHubMcpClient {
|
|
|
127
171
|
}
|
|
128
172
|
|
|
129
173
|
if (!res.ok) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
174
|
+
let detail = '';
|
|
175
|
+
try { detail = await res.text(); } catch { /* best effort */ }
|
|
176
|
+
// session 未建立/过期:FastMCP 回 HTTP 400 + body 含 "Missing session ID"
|
|
177
|
+
if (res.status === 400 && detail.includes('Missing session')) {
|
|
178
|
+
return SESSION_EXPIRED;
|
|
134
179
|
}
|
|
180
|
+
if (!quiet) console.warn(`[sillyhub-mcp] ${tag} → HTTP ${res.status} ${String(detail).slice(0, 200)}`);
|
|
135
181
|
return null;
|
|
136
182
|
}
|
|
137
183
|
|
|
@@ -167,6 +213,95 @@ export class SillyHubMcpClient {
|
|
|
167
213
|
return rpc.result || null;
|
|
168
214
|
}
|
|
169
215
|
|
|
216
|
+
/**
|
|
217
|
+
* 确保已建立 MCP session(惰性 initialize,并发安全)。已初始化直接返回 true;
|
|
218
|
+
* 初始化进行中则复用同一 promise。失败返回 false 且不留半成品状态。
|
|
219
|
+
* @param {boolean} [quiet] - 静默初始化失败警告(探测路径默认用)
|
|
220
|
+
* @returns {Promise<boolean>}
|
|
221
|
+
*/
|
|
222
|
+
async _ensureSession(quiet = false) {
|
|
223
|
+
if (this._sessionId) return true;
|
|
224
|
+
if (this._sessionPromise) return this._sessionPromise;
|
|
225
|
+
this._sessionPromise = this._initialize(quiet).finally(() => { this._sessionPromise = null; });
|
|
226
|
+
return this._sessionPromise;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* MCP initialize 握手:POST initialize → 从响应 header(或 body _meta)读 mcp-session-id。
|
|
231
|
+
* 成功存 this._sessionId;失败返回 false(best-effort,warn 不抛)。
|
|
232
|
+
*
|
|
233
|
+
* 注:协议要求 initialize 后发 notifications/initialized,本客户端只做探测
|
|
234
|
+
* (tools/list / 只读 tool),实测 FastMCP 不强制该通知即可用(2026-08-14 验证),
|
|
235
|
+
* 为省一次 RPC 不发;若未来 server 强校验再补。
|
|
236
|
+
* @returns {Promise<boolean>}
|
|
237
|
+
*/
|
|
238
|
+
async _initialize(quiet = false) {
|
|
239
|
+
this._rpcId += 1;
|
|
240
|
+
const id = this._rpcId;
|
|
241
|
+
const body = {
|
|
242
|
+
jsonrpc: '2.0',
|
|
243
|
+
id,
|
|
244
|
+
method: 'initialize',
|
|
245
|
+
params: {
|
|
246
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
247
|
+
capabilities: {},
|
|
248
|
+
// MCP 2025-11-25 协议要求 clientInfo 必含 name + version,缺 version FastMCP
|
|
249
|
+
// 回 -32602 Invalid request parameters(2026-08-14 实测锁定)
|
|
250
|
+
clientInfo: { name: 'sillyspec-cli', version: getVersion() },
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
const controller = new AbortController();
|
|
254
|
+
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
255
|
+
let res;
|
|
256
|
+
try {
|
|
257
|
+
res = await fetch(this._endpoint, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: {
|
|
260
|
+
Authorization: `Bearer ${this._token}`,
|
|
261
|
+
'Content-Type': 'application/json',
|
|
262
|
+
Accept: 'application/json, text/event-stream',
|
|
263
|
+
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify(body),
|
|
266
|
+
signal: controller.signal,
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (!quiet) {
|
|
270
|
+
const msg = err && err.message ? err.message : String(err);
|
|
271
|
+
console.warn(`[sillyhub-mcp] initialize 请求失败: ${msg}`);
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
} finally {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!res.ok) {
|
|
279
|
+
if (!quiet) console.warn(`[sillyhub-mcp] initialize → HTTP ${res.status}`);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 优先取 header mcp-session-id(FastMCP 实测返回);缺失再查 body _meta.sessionId。
|
|
284
|
+
// ⚠️ 无论 header 是否有 session,都必须读完 body(消费 SSE 流)——FastMCP 在
|
|
285
|
+
// initialize 响应流未消费完时 session 未就绪,后续 tools/call 回 -32602
|
|
286
|
+
// "Invalid request parameters"(2026-08-14 实测:node 只读 header 不读 body 必现,
|
|
287
|
+
// python 读完 body 则过)。
|
|
288
|
+
let sid = res.headers.get('mcp-session-id');
|
|
289
|
+
let text = '';
|
|
290
|
+
try { text = await res.text(); } catch { /* best effort */ }
|
|
291
|
+
if (!sid && text) {
|
|
292
|
+
const rpc = this._parseSseResponse(text, id);
|
|
293
|
+
if (rpc && rpc.result && rpc.result._meta && typeof rpc.result._meta.sessionId === 'string') {
|
|
294
|
+
sid = rpc.result._meta.sessionId;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!sid) {
|
|
298
|
+
if (!quiet) console.warn('[sillyhub-mcp] initialize 响应无 mcp-session-id');
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
this._sessionId = sid;
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
170
305
|
/**
|
|
171
306
|
* 解析 SSE 响应:取含 JSON-RPC result/error 的事件;优先 id 精确匹配,否则取首个。
|
|
172
307
|
* 事件间空行分隔;事件内 data: 行可多行,拼装时用 \n 连接。
|
|
@@ -47,6 +47,7 @@ export const CUSTOM_KINDS = new Set([
|
|
|
47
47
|
'cross-task-contract', // task 卡片 provides/expects_from 对账(plan-postcheck)
|
|
48
48
|
'design-file-coverage', // design.md 文件清单 vs allowed_paths 覆盖(plan-postcheck)
|
|
49
49
|
'task-id-continuity', // task-NN id 连续性
|
|
50
|
+
'task-plan-reconciliation', // plan.md 声明任务 ↔ tasks/ 卡片双向对账(plan-postcheck)
|
|
50
51
|
'design-readiness', // design.md 进入 plan 前的章节就绪检查(validateDesignForPlan)
|
|
51
52
|
'task-card-fields', // task-NN.md 卡片字段存在性(validateBlueprintConsistency + validatePlanFeasibility)
|
|
52
53
|
'verify-conclusion-gate', // verify-result.md 结论 PASS/FAIL 门控
|
|
@@ -321,6 +322,20 @@ const PLAN_RULES = [
|
|
|
321
322
|
spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)。',
|
|
322
323
|
failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。',
|
|
323
324
|
},
|
|
325
|
+
// task-plan-reconciliation(custom):plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b)。
|
|
326
|
+
// 修复场景:plan 列 17 个任务只生成 12 张卡(尾部文档同步类任务漏卡),execute 审计只看
|
|
327
|
+
// 卡片 → 漏卡任务零失败信号(sillyhub conversation-driven T-13~T-17 实证)。
|
|
328
|
+
// ${declared}/${cards} replaceAll。
|
|
329
|
+
{
|
|
330
|
+
id: 'plan.task-plan-reconciliation', stage: 'plan', source: 'validateBlueprintConsistency', severity: 'error', kind: 'task-plan-reconciliation',
|
|
331
|
+
target: { root: 'change', path: 'tasks' },
|
|
332
|
+
data: {
|
|
333
|
+
messageMissingCards: 'plan.md 声明了 ${declared} 个任务,但 tasks/ 只有部分卡片。缺卡: ${missing}\n 无卡片的任务不进 Wave、不受 execute 审计(allowed_paths/review.json 都不覆盖)→ 必然漏做(典型:尾部文档同步类任务)。\n 修复:为每个缺卡任务补 task-NN.md(含 allowed_paths/验收标准),或从 plan.md 删除该任务。',
|
|
334
|
+
messageOrphanCards: 'tasks/ 存在 plan.md 未声明的卡片: ${orphans}\n 这些任务不进任何 Wave,execute 不会执行。修复:在 plan.md 补声明,或删除孤儿卡片。',
|
|
335
|
+
},
|
|
336
|
+
spec: 'plan.md 中声明的每个 task-NN 必须有对应 tasks/task-NN.md 卡片(反向亦然),否则阻断——无卡片任务不受 execute 审计,必然漏做。',
|
|
337
|
+
failMessage: 'plan.md 任务与 tasks/ 卡片不对账',
|
|
338
|
+
},
|
|
324
339
|
// task-id-continuity(custom):task-NN id 从 task-01 起连续。算法留 validatePlanFeasibility。
|
|
325
340
|
// ${expected}/${actual} replaceAll(零填充 2 位)。
|
|
326
341
|
{
|
package/src/stage-contract.js
CHANGED
|
@@ -12,6 +12,7 @@ import { detectChangeRisk, checkIntegrationEvidence, VERIFICATION_NEEDS, RISK_LE
|
|
|
12
12
|
import { SCAN_REQUIRED_DOCS, AUXILIARY_STAGES } from './constants.js'
|
|
13
13
|
import { evaluateRules } from './stage-contract-engine.js'
|
|
14
14
|
import { getRule } from './stage-contract-spec.js'
|
|
15
|
+
import { parseAllowedPaths } from './stages/plan-postcheck.js'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* 校验结果
|
|
@@ -381,11 +382,13 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
|
|
|
381
382
|
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/i.test(f))
|
|
382
383
|
for (const taskFile of taskFiles) {
|
|
383
384
|
const taskContent = readFileSync(join(tasksDir, taskFile), 'utf8')
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
385
|
+
// 复用 parseAllowedPaths(坑6①:认 inline 数组/缩进块/顶格块 + 剥反引号 + CRLF 归一,
|
|
386
|
+
// 消除此处内联正则与 plan-postcheck 的解析漂移)。卡片无 frontmatter 时退全文扫描
|
|
387
|
+
// (包裹伪 frontmatter 复用同一解析器)——原内联正则是全文匹配语义,entry-point-wiring
|
|
388
|
+
// 只关心路径覆盖,卡片格式合法性归 plan-postcheck 把关,此处不收紧。
|
|
389
|
+
const paths = parseAllowedPaths(taskContent)
|
|
390
|
+
const effective = paths.length > 0 ? paths : parseAllowedPaths(`---\n${taskContent}\n---`)
|
|
391
|
+
for (const p of effective) allAllowedPaths.add(p.toLowerCase())
|
|
389
392
|
}
|
|
390
393
|
}
|
|
391
394
|
// 也从 plan.md 文件变更清单中收集
|
package/src/stages/brainstorm.js
CHANGED
|
@@ -32,6 +32,7 @@ export const definition = {
|
|
|
32
32
|
|
|
33
33
|
### 操作
|
|
34
34
|
1. 读取项目总览 \`{SPEC_ROOT}/docs/<project>/scan/PROJECT.md\` + 共享规范 + 子项目上下文
|
|
35
|
+
- 漂移事实(CLI 算):{SCAN_STALENESS}
|
|
35
36
|
2. 加载项目信息:\`cat {SPEC_ROOT}/projects/*.yaml 2>/dev/null\`
|
|
36
37
|
3. 加载本地配置:\`cat {SPEC_ROOT}/local.yaml 2>/dev/null\`
|
|
37
38
|
4. 棕地项目:读取 {SPEC_ROOT}/docs/<project>/scan/ 下的 STRUCTURE.md、CONVENTIONS.md、ARCHITECTURE.md
|
|
@@ -79,7 +80,6 @@ export const definition = {
|
|
|
79
80
|
maxWaitRounds: 8,
|
|
80
81
|
waitReason: '等待用户回答需求问题或澄清',
|
|
81
82
|
waitOptions: ['回答见--answer', '信息够了,进入方案讨论'],
|
|
82
|
-
waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
|
|
83
83
|
prompt: `通过对话探索需求细节、分析原型(如有)、判断拆分/批量、并对歧义点做需求澄清 Grill。本步骤合并了原有的对话探索、原型分析、需求范围评估、需求澄清 Grill 四个环节——按下方 A→D 顺序按需执行。提问遵守「一次只问一个」(见 D 节追问策略):必须问的问题逐个问,能查代码/文档解决的不问;不要把多个歧义打包成一屏清单一次抛给用户(用户记不住、答不全,反而漏掉关键约束);也不要为凑数把单个问题拖成多轮。
|
|
84
84
|
|
|
85
85
|
### A. 原型/设计图分析(如用户提供)
|