sillyspec 3.26.9 → 3.26.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 (98) 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 +111 -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 -81
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -675
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +198 -9
  62. package/src/run/complete.js +225 -24
  63. package/src/run/concurrent-detect.js +22 -0
  64. package/src/run/gates.js +755 -755
  65. package/src/run/prompt.js +761 -721
  66. package/src/run/quick-audit.js +155 -146
  67. package/src/run/shared.js +1268 -1197
  68. package/src/run/stage.js +487 -479
  69. package/src/scan-postcheck.js +371 -371
  70. package/src/setup.js +416 -416
  71. package/src/sillyhub-mcp/client.js +529 -529
  72. package/src/spec-sync.js +323 -0
  73. package/src/stage-contract.js +897 -897
  74. package/src/stage-review.js +549 -549
  75. package/src/stages/archive.js +155 -154
  76. package/src/stages/brainstorm-auto.js +236 -236
  77. package/src/stages/brainstorm.js +596 -597
  78. package/src/stages/cmd-existence.js +145 -145
  79. package/src/stages/doctor.js +371 -371
  80. package/src/stages/execute.js +1003 -991
  81. package/src/stages/explore.js +34 -34
  82. package/src/stages/index.js +29 -29
  83. package/src/stages/knowledge.js +498 -498
  84. package/src/stages/plan-postcheck.js +1302 -1281
  85. package/src/stages/plan.js +625 -604
  86. package/src/stages/quick.js +151 -143
  87. package/src/stages/scan.js +606 -605
  88. package/src/stages/status.js +64 -64
  89. package/src/stages/verify.js +264 -261
  90. package/src/sync.js +1223 -1125
  91. package/src/task-review.js +1019 -1019
  92. package/src/verify-postcheck.js +1186 -1186
  93. package/src/workflow.js +600 -600
  94. package/src/worktree-apply.js +1213 -1136
  95. package/src/worktree-deps.js +353 -353
  96. package/src/worktree.js +1481 -1437
  97. package/templates/workflows/archive-impact.yaml +79 -79
  98. package/templates/workflows/scan-docs.yaml +132 -132
@@ -1,210 +1,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
- // 负面缓存: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
+ // 负面缓存: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
+ }