sillyspec 3.26.10 → 3.26.13

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 (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
@@ -1,210 +1,246 @@
1
- /**
2
- * SillyHub 能力探测(D-005 双后端 fallback 的关键判定)
3
- *
4
- * 定位(design.md §Phase4 / §接口定义):probeSillyHub 是"能力探测",决定 dispatch 用 SillyHub
5
- * 还是 Local 后端。无 MCP 配置 / daemon 不可达 / worktree 越界 → 返回 unavailable → 调用方全程
6
- * Local(零回归)。纯 JS 可测(task-08 mock client 验三分支);不直接碰 lease,只感知 daemon 连通性。
7
- *
8
- * 铁律(design.md R-06 / task-01 constraints):
9
- * - 探测失败保守 fallback(返回 unavailable),**绝不抛异常阻断 execute**(client 契约异常时再兜 try/catch)。
10
- * - TTL 可配(local.yaml dispatch.probe_ttl_ms),不硬编码。
11
- * - 无 MCP 配置时同步快速路径返回,**不发网络**(零回归的关键)。
12
- * - 负面结果 TTL 缓存(daemon 抖动期免反复探测,R-06);正面结果不缓存(让 daemon 恢复/退化及时反映)。
13
- */
14
-
15
- import { existsSync, readFileSync } from 'node:fs';
16
- import { join, relative, isAbsolute, sep } from 'node:path';
17
- import jsYaml from 'js-yaml';
18
- import { SillyHubMcpClient } from '../sillyhub-mcp/client.js';
19
- import { readMcpConfig } from '../sillyhub-mcp/config.js';
20
- import { setPathAProbeResult } from './backends/sillyhub-mcp.js';
21
-
22
- /** 默认负面缓存 TTL(毫秒)。可被 local.yaml dispatch.probe_ttl_ms 或 ttlMs 参数覆盖。 */
23
- export const DEFAULT_PROBE_TTL_MS = 60_000;
24
-
25
- // 负面缓存:configFingerprint → { result: {available,reason}, expiresAt }
26
- // 仅缓存网络派生的 unavailable 结果(daemon-unreachable),避免 daemon 抖动期反复探测(R-06)。
27
- // no-config 由 env 同步派生(不入缓存);worktree-outside-root 依赖路径入参(每次重算,不入缓存)。
28
- const negativeCache = new Map();
29
-
30
- /**
31
- * 读 local.yaml 的 dispatch.probe_ttl_ms(best-effort,绝不抛)。
32
- * 文件不存在 / 解析失败 / 无 dispatch 段 / 非正数 → 返回 undefined(调用方降级默认值)。
33
- * 读法参考 src/sync.js readLocalYaml 的 best-effort 风格,但用 js-yaml 解析嵌套段(更稳健)。
34
- */
35
- function readProbeTtlFromLocalYaml(cwd) {
36
- try {
37
- const p = join(cwd, '.sillyspec', 'local.yaml');
38
- if (!existsSync(p)) return undefined;
39
- const doc = jsYaml.load(readFileSync(p, 'utf8'));
40
- if (!doc || typeof doc !== 'object') return undefined;
41
- const dispatch = doc.dispatch;
42
- if (!dispatch || typeof dispatch !== 'object') return undefined;
43
- const v = dispatch.probe_ttl_ms;
44
- return typeof v === 'number' && v > 0 ? v : undefined;
45
- } catch {
46
- return undefined;
47
- }
48
- }
49
-
50
- /**
51
- * 解析生效 TTL。优先级:显式 ttlMs 参数 > local.yaml dispatch.probe_ttl_ms > 默认常量。
52
- */
53
- function resolveTtl(ttlMs, cwd) {
54
- if (typeof ttlMs === 'number' && ttlMs > 0) return ttlMs;
55
- const fromYaml = readProbeTtlFromLocalYaml(cwd);
56
- if (typeof fromYaml === 'number') return fromYaml;
57
- return DEFAULT_PROBE_TTL_MS;
58
- }
59
-
60
- /**
61
- * 配置指纹(负面缓存 key)。用 local.yaml mcp 段或 env 的 URL 标识 daemon 身份;
62
- * token 不入 key(避免敏感信息驻留缓存,保密语义不变)。token 轮换后最迟 TTL 内自动失效重探。
63
- */
64
- function configFingerprint(cwd) {
65
- return readMcpConfig(cwd)?.url || '';
66
- }
67
-
68
- /**
69
- * 判断 worktreePath 是否在 rootPath 目录内(含相等,R-08)。
70
- * 段级 '..' 精确匹配:兼容跨盘符(Windows 不同盘 → rel 绝对路径 → 越界)与字面 '..' 前缀目录名
71
- * (如 '..foo' 目录不会被误判越界,区别于粗放的 startsWith('..') 写法)。
72
- */
73
- function isWithinRoot(rootPath, worktreePath) {
74
- const rel = relative(rootPath, worktreePath);
75
- if (rel === '') return true; // worktree === root,边界视为在内
76
- if (isAbsolute(rel)) return false; // 跨盘符(Windows)→ rel 绝对路径 → 越界
77
- const segments = rel.split(sep);
78
- return !segments.some((s) => s === '..');
79
- }
80
-
81
- /**
82
- * 从 tools/list 返回的 tools 数组探测路径A 是否支持(task-11,D-005)。
83
- *
84
- * 命中条件(全中才 true,任一缺失 → false,保守 R-04):
85
- * - 找到 `name === 'dispatch_worker'` 的 tool
86
- * - 其 `inputSchema.properties` 同时声明 `worktree_path` 与 `worker_prompt`(caller-worktree +
87
- * prompt 覆写两处 daemon 侧落地标志,design §7.3)
88
- *
89
- * 纯函数(不调网络),独立可测(task-11 三分支:含字段→true / 缺字段→false / 异常输入→false)。
90
- * @param {Array<{name:string, inputSchema?:object}>|null} tools - client.listTools() 返回的 tools 数组
91
- * @returns {boolean} 路径A schema 全命中 → true;否则 false
92
- */
93
- export function detectPathAFromTools(tools) {
94
- if (!Array.isArray(tools)) return false;
95
- const dw = tools.find((t) => t && t.name === 'dispatch_worker');
96
- if (!dw) return false;
97
- const props = dw.inputSchema && dw.inputSchema.properties;
98
- if (!props || typeof props !== 'object') return false;
99
- return Boolean(props.worktree_path) && Boolean(props.worker_prompt);
100
- }
101
-
102
- /**
103
- * 预热路径A 探测缓存(task-11):probe.js daemon 可达后 best-effort 调 client.listTools,
104
- * 把 detectPathAFromTools 结果写回 sillyhub-mcp.js 缓存供 isPathASupported 同步读。
105
- *
106
- * best-effort:client 无 listTools 方法(旧 mock)/ listTools 异常 / 返回非数组 → 保守写 false
107
- * (R-04,绝不硬试路径A),**不抛穿 probe**(探测失败不影响 availability 判定)。
108
- * @param {object} cli - SillyHubMcpClient 实例(或含 listTools 的 mock)
109
- */
110
- async function preheatPathAProbe(cli) {
111
- try {
112
- if (!cli || typeof cli.listTools !== 'function') return; // 旧 mock 无 listTools → 不预热(留默认 false)
113
- const tools = await cli.listTools();
114
- setPathAProbeResult(detectPathAFromTools(tools));
115
- } catch (err) {
116
- // 探测异常 → 保守 false(R-04);best-effort 不阻断 probe 主流程
117
- const msg = err && err.message ? err.message : String(err);
118
- console.warn(`[dispatch/probe] 路径A schema 探测异常(保守判 false): ${msg}`);
119
- setPathAProbeResult(false);
120
- }
121
- }
122
-
123
- /**
124
- * 能力探测:决定 dispatch 是否可用 SillyHub 后端。
125
- *
126
- * 决策顺序(design.md §Phase4 / task-01 implementation):
127
- * 1. env 快速路径(无网络):缺 URL/TOKEN → no-config
128
- * 2. 负面缓存命中且未过 TTL → 直接返回缓存(无网络,R-06)
129
- * 3. 连通性探测 client.probeDaemon():false → 缓存 daemon-unreachable 并返回
130
- * 3.5 路径A schema 预热(task-11):daemon 可达 → best-effort client.listTools() 写
131
- * isPathASupported 缓存(dispatch_worker schema 含 worktree_path + worker_prompt)
132
- * 4. root_path 校验(R-08 best-effort):rootPath 显式传则用;未传则 best-effort 从 daemon
133
- * 拿(client.getRootPath,复用 tools/list);worktreePath 同传且越界 → worktree-outside-root
134
- * 5. 全通过 → available=true(不缓存正面结果)
135
- *
136
- * @param {object} [opts]
137
- * @param {object} [opts.client] - SillyHubMcpClient 实例;缺省 new SillyHubMcpClient()(经 readMcpConfig 读 local.yaml mcp 段 + env)
138
- * @param {string} [opts.worktreePath] - SillySpec worktree 绝对路径(root_path 校验用)
139
- * @param {string} [opts.rootPath] - daemon ws.root_path;未传(undefined)时 best-effort 从 daemon
140
- * 拿(task-12 #5),拿不到则跳过越界校验(不判 unavailable)
141
- * @param {number} [opts.ttlMs] - 负面缓存 TTL 覆盖(优先于 local.yaml)
142
- * @returns {Promise<{ available: boolean, reason?: string }>}
143
- */
144
- export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs } = {}) {
145
- // 1. 同步快速路径:无 local.yaml mcp 段且 env 缺凭据即 unavailable(不发网络,零回归关键)。
146
- // readMcpConfig 纯 fs + env 读(best-effort 不抛不发网络),返回 null = 两源都缺 → no-config。
147
- if (!readMcpConfig(process.cwd())) {
148
- return { available: false, reason: 'no-config' };
149
- }
150
-
151
- // 2. 负面缓存命中(未过 TTL)→ 直接返回,不发网络(R-06 抖动期免反复探测)
152
- const fp = configFingerprint(process.cwd());
153
- const cached = negativeCache.get(fp);
154
- if (cached && cached.expiresAt > Date.now()) {
155
- return cached.result;
156
- }
157
-
158
- // 3. 连通性探测:client.probeDaemon 自身 best-effort 不抛;再兜一层 try/catch,
159
- // 即便 client 契约异常抛出也保守判 unavailable(铁律:绝不抛穿 execute)
160
- const cli = client || new SillyHubMcpClient({ cwd: process.cwd() });
161
- let reachable;
162
- try {
163
- reachable = await cli.probeDaemon();
164
- } catch (err) {
165
- const msg = err && err.message ? err.message : String(err);
166
- console.warn(`[dispatch/probe] probeDaemon 抛异常(保守判 unavailable): ${msg}`);
167
- reachable = false;
168
- }
169
- if (!reachable) {
170
- const result = { available: false, reason: 'daemon-unreachable' };
171
- const ttl = resolveTtl(ttlMs, process.cwd());
172
- negativeCache.set(fp, { result, expiresAt: Date.now() + ttl });
173
- return result;
174
- }
175
-
176
- // 3.5 路径A schema 探测预热(task-11,D-005):daemon 可达后 best-effort 调 client.listTools,
177
- // 把 dispatch_worker schema 含 worktree_path + worker_prompt 的结果写缓存供
178
- // isPathASupported 同步读。best-effort:失败保守 false(R-04),不影响 availability。
179
- await preheatPathAProbe(cli);
180
-
181
- // 4. root_path 校验(R-08,best-effort):rootPath 显式传入则直接用;未传入时 best-effort 从
182
- // daemon 拿(task-12 #5,复用 listTools RPC:daemon 若在 tools/list 顶层暴露 root_path 则
183
- // 自动适配;当前 gateway 不暴露 → null → 跳过越界校验,不判 unavailable)。
184
- // 仅 worktreePath 也传时才校验(无 worktree 无谓越界判定);任一缺失则跳过。
185
- if (worktreePath) {
186
- let effectiveRoot = rootPath;
187
- if (effectiveRoot === undefined && cli && typeof cli.getRootPath === 'function') {
188
- try {
189
- effectiveRoot = await cli.getRootPath();
190
- } catch (err) {
191
- const msg = err && err.message ? err.message : String(err);
192
- console.warn(`[dispatch/probe] rootPath best-effort 拿取异常(跳过越界校验): ${msg}`);
193
- effectiveRoot = null;
194
- }
195
- }
196
- if (effectiveRoot && !isWithinRoot(effectiveRoot, worktreePath)) {
197
- return { available: false, reason: 'worktree-outside-root' };
198
- }
199
- }
200
-
201
- // 5. 全通过:不缓存正面结果,下次重新探测(让 daemon 恢复/退化及时反映)
202
- return { available: true };
203
- }
204
-
205
- /**
206
- * 清空负面缓存(测试用;生产中 TTL 自然过期即可)。
207
- */
208
- export function clearProbeCache() {
209
- negativeCache.clear();
210
- }
1
+ /**
2
+ * SillyHub 能力探测(D-005 双后端 fallback 的关键判定)
3
+ *
4
+ * 定位(design.md §Phase4 / §接口定义):probeSillyHub 是"能力探测",决定 dispatch 用 SillyHub
5
+ * 还是 Local 后端。无 MCP 配置 / daemon 不可达 / worktree 越界 → 返回 unavailable → 调用方全程
6
+ * Local(零回归)。纯 JS 可测(task-08 mock client 验三分支);不直接碰 lease,只感知 daemon 连通性。
7
+ *
8
+ * 铁律(design.md R-06 / task-01 constraints):
9
+ * - 探测失败保守 fallback(返回 unavailable),**绝不抛异常阻断 execute**(client 契约异常时再兜 try/catch)。
10
+ * - TTL 可配(local.yaml dispatch.probe_ttl_ms),不硬编码。
11
+ * - 无 MCP 配置时同步快速路径返回,**不发网络**(零回归的关键)。
12
+ * - 负面结果 TTL 缓存(daemon 抖动期免反复探测,R-06);正面结果不缓存(让 daemon 恢复/退化及时反映)。
13
+ */
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join, relative, isAbsolute, sep } from 'node:path';
17
+ import jsYaml from 'js-yaml';
18
+ import { SillyHubMcpClient } from '../sillyhub-mcp/client.js';
19
+ import { readMcpConfig } from '../sillyhub-mcp/config.js';
20
+ import { setPathAProbeResult } from './backends/sillyhub-mcp.js';
21
+
22
+ /** 默认负面缓存 TTL(毫秒)。可被 local.yaml dispatch.probe_ttl_ms 或 ttlMs 参数覆盖。 */
23
+ export const DEFAULT_PROBE_TTL_MS = 60_000;
24
+
25
+ /**
26
+ * HUB-12a:probe 总超时。链路最坏 initialize(10s) + tools/call(10s) + tools/list(10s) 串行,
27
+ * 慢/挂 daemon 下此前要 ~30-50s 才降级,阻塞 execute。默认 25s 截断(可达 daemon 毫秒级
28
+ * 响应,不受影响);opts.totalTimeoutMs 可覆盖。超时按 daemon-unreachable 语义进负面缓存。
29
+ */
30
+ const DEFAULT_PROBE_TOTAL_TIMEOUT_MS = 25_000;
31
+
32
+ // 负面缓存:configFingerprint → { result: {available,reason}, expiresAt }
33
+ // 仅缓存网络派生的 unavailable 结果(daemon-unreachable),避免 daemon 抖动期反复探测(R-06)。
34
+ // no-config 由 env 同步派生(不入缓存);worktree-outside-root 依赖路径入参(每次重算,不入缓存)。
35
+ const negativeCache = new Map();
36
+
37
+ /**
38
+ * 读 local.yaml 的 dispatch.probe_ttl_ms(best-effort,绝不抛)。
39
+ * 文件不存在 / 解析失败 / 无 dispatch 段 / 非正数 → 返回 undefined(调用方降级默认值)。
40
+ * 读法参考 src/sync.js readLocalYaml 的 best-effort 风格,但用 js-yaml 解析嵌套段(更稳健)。
41
+ */
42
+ function readProbeTtlFromLocalYaml(cwd) {
43
+ try {
44
+ const p = join(cwd, '.sillyspec', 'local.yaml');
45
+ if (!existsSync(p)) return undefined;
46
+ const doc = jsYaml.load(readFileSync(p, 'utf8'));
47
+ if (!doc || typeof doc !== 'object') return undefined;
48
+ const dispatch = doc.dispatch;
49
+ if (!dispatch || typeof dispatch !== 'object') return undefined;
50
+ const v = dispatch.probe_ttl_ms;
51
+ return typeof v === 'number' && v > 0 ? v : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 解析生效 TTL。优先级:显式 ttlMs 参数 > local.yaml dispatch.probe_ttl_ms > 默认常量。
59
+ */
60
+ function resolveTtl(ttlMs, cwd) {
61
+ if (typeof ttlMs === 'number' && ttlMs > 0) return ttlMs;
62
+ const fromYaml = readProbeTtlFromLocalYaml(cwd);
63
+ if (typeof fromYaml === 'number') return fromYaml;
64
+ return DEFAULT_PROBE_TTL_MS;
65
+ }
66
+
67
+ /**
68
+ * 配置指纹(负面缓存 key)。用 local.yaml mcp 段或 env 的 URL 标识 daemon 身份;
69
+ * token 不入 key(避免敏感信息驻留缓存,保密语义不变)。token 轮换后最迟 TTL 内自动失效重探。
70
+ */
71
+ function configFingerprint(cwd) {
72
+ return readMcpConfig(cwd)?.url || '';
73
+ }
74
+
75
+ /**
76
+ * 判断 worktreePath 是否在 rootPath 目录内(含相等,R-08)。
77
+ * 段级 '..' 精确匹配:兼容跨盘符(Windows 不同盘 → rel 绝对路径 → 越界)与字面 '..' 前缀目录名
78
+ * (如 '..foo' 目录不会被误判越界,区别于粗放的 startsWith('..') 写法)。
79
+ */
80
+ function isWithinRoot(rootPath, worktreePath) {
81
+ const rel = relative(rootPath, worktreePath);
82
+ if (rel === '') return true; // worktree === root,边界视为在内
83
+ if (isAbsolute(rel)) return false; // 跨盘符(Windows)→ rel 绝对路径 → 越界
84
+ const segments = rel.split(sep);
85
+ return !segments.some((s) => s === '..');
86
+ }
87
+
88
+ /**
89
+ * 从 tools/list 返回的 tools 数组探测路径A 是否支持(task-11,D-005)。
90
+ *
91
+ * 命中条件(全中才 true,任一缺失 → false,保守 R-04):
92
+ * - 找到 `name === 'dispatch_worker'` 的 tool
93
+ * - 其 `inputSchema.properties` 同时声明 `worktree_path` 与 `worker_prompt`(caller-worktree +
94
+ * prompt 覆写两处 daemon 侧落地标志,design §7.3)
95
+ *
96
+ * 纯函数(不调网络),独立可测(task-11 三分支:含字段→true / 缺字段→false / 异常输入→false)。
97
+ * @param {Array<{name:string, inputSchema?:object}>|null} tools - client.listTools() 返回的 tools 数组
98
+ * @returns {boolean} 路径A schema 全命中 → true;否则 false
99
+ */
100
+ export function detectPathAFromTools(tools) {
101
+ if (!Array.isArray(tools)) return false;
102
+ const dw = tools.find((t) => t && t.name === 'dispatch_worker');
103
+ if (!dw) return false;
104
+ const props = dw.inputSchema && dw.inputSchema.properties;
105
+ if (!props || typeof props !== 'object') return false;
106
+ return Boolean(props.worktree_path) && Boolean(props.worker_prompt);
107
+ }
108
+
109
+ /**
110
+ * 能力探测:决定 dispatch 是否可用 SillyHub 后端。
111
+ *
112
+ * 决策顺序(design.md §Phase4 / task-01 implementation):
113
+ * 1. env 快速路径(无网络):缺 URL/TOKEN → no-config
114
+ * 2. 负面缓存命中且未过 TTL → 直接返回缓存(无网络,R-06)
115
+ * 3. 连通性探测 client.probeDaemon():false → 缓存 daemon-unreachable 并返回
116
+ * 3.5 路径A schema 预热 + root_path 获取(HUB-12a 合并):daemon 可达后**一次**
117
+ * tools/list 同时喂 detectPathAFromTools 预热与 root_path 越界校验(此前
118
+ * listTools + getRootPath 各发一次同 method 请求)
119
+ * 4. worktreePath 越界校验(R-08 best-effort)
120
+ * 5. 全通过 → available=true(不缓存正面结果)
121
+ *
122
+ * 整链路受 totalTimeoutMs 总超时保护(HUB-12a):超时按 daemon-unreachable 进负面缓存。
123
+ *
124
+ * @param {object} [opts]
125
+ * @param {object} [opts.client] - SillyHubMcpClient 实例;缺省 new SillyHubMcpClient()(经 readMcpConfig 读 local.yaml mcp 段 + env)
126
+ * @param {string} [opts.worktreePath] - SillySpec worktree 绝对路径(root_path 校验用)
127
+ * @param {string} [opts.rootPath] - daemon ws.root_path;未传(undefined)时 best-effort 从 daemon
128
+ * 拿(task-12 #5),拿不到则跳过越界校验(不判 unavailable)
129
+ * @param {number} [opts.ttlMs] - 负面缓存 TTL 覆盖(优先于 local.yaml)
130
+ * @param {number} [opts.totalTimeoutMs] - 总超时覆盖(默认 25s,HUB-12a)
131
+ * @returns {Promise<{ available: boolean, reason?: string }>}
132
+ */
133
+ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, totalTimeoutMs } = {}) {
134
+ // 1. 同步快速路径:无 local.yaml mcp 段且 env 缺凭据即 unavailable(不发网络,零回归关键)。
135
+ // readMcpConfig 纯 fs + env 读(best-effort 不抛不发网络),返回 null = 两源都缺 → no-config。
136
+ if (!readMcpConfig(process.cwd())) {
137
+ return { available: false, reason: 'no-config' };
138
+ }
139
+
140
+ // 2. 负面缓存命中(未过 TTL)→ 直接返回,不发网络(R-06 抖动期免反复探测)
141
+ const fp = configFingerprint(process.cwd());
142
+ const cached = negativeCache.get(fp);
143
+ if (cached && cached.expiresAt > Date.now()) {
144
+ return cached.result;
145
+ }
146
+
147
+ const cli = client || new SillyHubMcpClient({ cwd: process.cwd() });
148
+ const cacheNegative = (reason) => {
149
+ const result = { available: false, reason };
150
+ const ttl = resolveTtl(ttlMs, process.cwd());
151
+ negativeCache.set(fp, { result, expiresAt: Date.now() + ttl });
152
+ return result;
153
+ };
154
+
155
+ // HUB-12a:总超时兜底——慢/挂 daemon 不拖 execute。超时后后台请求由 client 自身
156
+ // 10s 请求超时自然终结(有界),probe 侧按不可达降级
157
+ const budgetMs = typeof totalTimeoutMs === 'number' && totalTimeoutMs > 0
158
+ ? totalTimeoutMs
159
+ : DEFAULT_PROBE_TOTAL_TIMEOUT_MS;
160
+
161
+ const probeBody = (async () => {
162
+ // 3. 连通性探测:client.probeDaemon 自身 best-effort 不抛;再兜一层 try/catch,
163
+ // 即便 client 契约异常抛出也保守判 unavailable(铁律:绝不抛穿 execute)
164
+ let reachable;
165
+ try {
166
+ reachable = await cli.probeDaemon();
167
+ } catch (err) {
168
+ const msg = err && err.message ? err.message : String(err);
169
+ console.warn(`[dispatch/probe] probeDaemon 抛异常(保守判 unavailable): ${msg}`);
170
+ reachable = false;
171
+ }
172
+ if (!reachable) return cacheNegative('daemon-unreachable');
173
+
174
+ // 3.5+4 合并(HUB-12a):一次 tools/list 同时拿 tools 数组(路径A schema 预热)与
175
+ // root_path(越界校验)。旧 client 无 listToolsWithMeta 时回退 listTools +
176
+ // getRootPath 各一次(兼容旧 mock)
177
+ let tools = null;
178
+ let toolsListRoot = undefined;
179
+ try {
180
+ if (typeof cli.listToolsWithMeta === 'function') {
181
+ const raw = await cli.listToolsWithMeta();
182
+ if (raw) {
183
+ if (Array.isArray(raw)) tools = raw;
184
+ else if (Array.isArray(raw.tools)) tools = raw.tools;
185
+ if (raw && typeof raw === 'object' && typeof raw.root_path === 'string' && raw.root_path) {
186
+ toolsListRoot = raw.root_path;
187
+ }
188
+ }
189
+ } else if (typeof cli.listTools === 'function') {
190
+ tools = await cli.listTools();
191
+ }
192
+ } catch (err) {
193
+ const msg = err && err.message ? err.message : String(err);
194
+ console.warn(`[dispatch/probe] tools/list 异常(路径A 保守判 false): ${msg}`);
195
+ tools = null;
196
+ }
197
+ try {
198
+ setPathAProbeResult(detectPathAFromTools(tools));
199
+ } catch (err) {
200
+ const msg = err && err.message ? err.message : String(err);
201
+ console.warn(`[dispatch/probe] 路径A schema 探测异常(保守判 false): ${msg}`);
202
+ setPathAProbeResult(false);
203
+ }
204
+
205
+ if (worktreePath) {
206
+ let effectiveRoot = rootPath;
207
+ if (effectiveRoot === undefined) {
208
+ effectiveRoot = toolsListRoot !== undefined ? toolsListRoot : null;
209
+ if (effectiveRoot === null && typeof cli.getRootPath === 'function') {
210
+ try {
211
+ effectiveRoot = await cli.getRootPath();
212
+ } catch (err) {
213
+ const msg = err && err.message ? err.message : String(err);
214
+ console.warn(`[dispatch/probe] rootPath best-effort 拿取异常(跳过越界校验): ${msg}`);
215
+ effectiveRoot = null;
216
+ }
217
+ }
218
+ }
219
+ if (effectiveRoot && !isWithinRoot(effectiveRoot, worktreePath)) {
220
+ return { available: false, reason: 'worktree-outside-root' };
221
+ }
222
+ }
223
+
224
+ // 5. 全通过:不缓存正面结果,下次重新探测(让 daemon 恢复/退化及时反映)
225
+ return { available: true };
226
+ })();
227
+
228
+ let timeoutId = null;
229
+ let raced;
230
+ try {
231
+ raced = await Promise.race([
232
+ probeBody,
233
+ new Promise((resolve) => { timeoutId = setTimeout(() => resolve(null), budgetMs) }),
234
+ ]);
235
+ } finally {
236
+ if (timeoutId) clearTimeout(timeoutId);
237
+ }
238
+ return raced || cacheNegative('probe-timeout');
239
+ }
240
+
241
+ /**
242
+ * 清空负面缓存(测试用;生产中 TTL 自然过期即可)。
243
+ */
244
+ export function clearProbeCache() {
245
+ negativeCache.clear();
246
+ }