sillyspec 3.26.11 → 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 (58) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +161 -12
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +184 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +31 -8
  31. package/src/run/complete-handlers.js +21 -14
  32. package/src/run/complete.js +1181 -1121
  33. package/src/run/gates.js +148 -25
  34. package/src/run/prompt.js +9 -5
  35. package/src/run/shared.js +138 -58
  36. package/src/run/stage.js +41 -1
  37. package/src/setup.js +8 -10
  38. package/src/sillyhub-mcp/client.js +74 -6
  39. package/src/spec-dir-typo.js +3 -24
  40. package/src/spec-sync.js +43 -8
  41. package/src/stage-contract.js +12 -38
  42. package/src/stages/archive.js +4 -3
  43. package/src/stages/brainstorm-auto.js +1 -1
  44. package/src/stages/brainstorm.js +2 -1
  45. package/src/stages/doctor.js +28 -13
  46. package/src/stages/execute.js +164 -153
  47. package/src/stages/plan-postcheck.js +49 -28
  48. package/src/stages/plan.js +57 -40
  49. package/src/stages/quick.js +3 -1
  50. package/src/stages/verify.js +1 -1
  51. package/src/sync.js +323 -221
  52. package/src/task-review.js +246 -61
  53. package/src/taskcard.js +178 -0
  54. package/src/verify-postcheck.js +48 -35
  55. package/src/worktree-apply.js +58 -34
  56. package/src/worktree-deps.js +120 -20
  57. package/src/worktree.js +55 -9
  58. package/.npmrc-publish +0 -2
@@ -22,6 +22,13 @@ import { setPathAProbeResult } from './backends/sillyhub-mcp.js';
22
22
  /** 默认负面缓存 TTL(毫秒)。可被 local.yaml dispatch.probe_ttl_ms 或 ttlMs 参数覆盖。 */
23
23
  export const DEFAULT_PROBE_TTL_MS = 60_000;
24
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
+
25
32
  // 负面缓存:configFingerprint → { result: {available,reason}, expiresAt }
26
33
  // 仅缓存网络派生的 unavailable 结果(daemon-unreachable),避免 daemon 抖动期反复探测(R-06)。
27
34
  // no-config 由 env 同步派生(不入缓存);worktree-outside-root 依赖路径入参(每次重算,不入缓存)。
@@ -99,27 +106,6 @@ export function detectPathAFromTools(tools) {
99
106
  return Boolean(props.worktree_path) && Boolean(props.worker_prompt);
100
107
  }
101
108
 
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
109
  /**
124
110
  * 能力探测:决定 dispatch 是否可用 SillyHub 后端。
125
111
  *
@@ -127,21 +113,24 @@ async function preheatPathAProbe(cli) {
127
113
  * 1. env 快速路径(无网络):缺 URL/TOKEN → no-config
128
114
  * 2. 负面缓存命中且未过 TTL → 直接返回缓存(无网络,R-06)
129
115
  * 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
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)
134
120
  * 5. 全通过 → available=true(不缓存正面结果)
135
121
  *
122
+ * 整链路受 totalTimeoutMs 总超时保护(HUB-12a):超时按 daemon-unreachable 进负面缓存。
123
+ *
136
124
  * @param {object} [opts]
137
125
  * @param {object} [opts.client] - SillyHubMcpClient 实例;缺省 new SillyHubMcpClient()(经 readMcpConfig 读 local.yaml mcp 段 + env)
138
126
  * @param {string} [opts.worktreePath] - SillySpec worktree 绝对路径(root_path 校验用)
139
127
  * @param {string} [opts.rootPath] - daemon ws.root_path;未传(undefined)时 best-effort 从 daemon
140
128
  * 拿(task-12 #5),拿不到则跳过越界校验(不判 unavailable)
141
129
  * @param {number} [opts.ttlMs] - 负面缓存 TTL 覆盖(优先于 local.yaml)
130
+ * @param {number} [opts.totalTimeoutMs] - 总超时覆盖(默认 25s,HUB-12a)
142
131
  * @returns {Promise<{ available: boolean, reason?: string }>}
143
132
  */
144
- export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs } = {}) {
133
+ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, totalTimeoutMs } = {}) {
145
134
  // 1. 同步快速路径:无 local.yaml mcp 段且 env 缺凭据即 unavailable(不发网络,零回归关键)。
146
135
  // readMcpConfig 纯 fs + env 读(best-effort 不抛不发网络),返回 null = 两源都缺 → no-config。
147
136
  if (!readMcpConfig(process.cwd())) {
@@ -155,51 +144,98 @@ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs } =
155
144
  return cached.result;
156
145
  }
157
146
 
158
- // 3. 连通性探测:client.probeDaemon 自身 best-effort 不抛;再兜一层 try/catch,
159
- // 即便 client 契约异常抛出也保守判 unavailable(铁律:绝不抛穿 execute)
160
147
  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' };
148
+ const cacheNegative = (reason) => {
149
+ const result = { available: false, reason };
171
150
  const ttl = resolveTtl(ttlMs, process.cwd());
172
151
  negativeCache.set(fp, { result, expiresAt: Date.now() + ttl });
173
152
  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;
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();
194
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;
195
196
  }
196
- if (effectiveRoot && !isWithinRoot(effectiveRoot, worktreePath)) {
197
- return { available: false, reason: 'worktree-outside-root' };
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
+ }
198
222
  }
199
- }
200
223
 
201
- // 5. 全通过:不缓存正面结果,下次重新探测(让 daemon 恢复/退化及时反映)
202
- return { available: true };
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');
203
239
  }
204
240
 
205
241
  /**
package/src/docs-check.js CHANGED
@@ -17,8 +17,13 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from '
17
17
  import { join } from 'node:path'
18
18
  import jsYaml from 'js-yaml'
19
19
 
20
- /** 提取 file.js:line / file.js:start-end 引用(.js/.mjs,反引号包裹与裸文本均命中,全文扫描 D-006) */
21
- const REF_RE = /([A-Za-z0-9_.\-\/]+\.(?:js|mjs)):(\d+)(?:-(\d+))?/g
20
+ /** 提取 file.js:line / file.js:start-end 引用(.js/.mjs,反引号包裹与裸文本均命中,全文扫描 D-006)。
21
+ * repo:// 前缀(2026-08-20):跨仓引用显式标记——`repo://<仓库名>/<路径>.js:行`。
22
+ * 不同设备兄弟仓库位置不同,未配映射时默认跳过本地校验(防跨设备误报);
23
+ * 在本机 .sillyspec/local.yaml 的 docs-check.cross_repo_roots 配
24
+ * `<仓库名>: <本机绝对路径>` 后,走与本地引用完全相同的层1(行号边界)+ 层2(关键词窗口)校验。
25
+ * 正则组:1=仓库名(可选),2=文件,3=start,4=end。 */
26
+ const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]+\.(?:js|mjs)):(\d+)(?:-(\d+))?/g
22
27
 
23
28
  /** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八) */
24
29
  const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
@@ -26,10 +31,10 @@ const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
26
31
  /**
27
32
  * 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
28
33
  * @param {string} projectRoot 源码仓根(local.yaml 在 <root>/.sillyspec/local.yaml)
29
- * @returns {{ paths?: string[], skip: string[], keywordAssert: boolean }}
34
+ * @returns {{ paths?: string[], skip: string[], keywordAssert: boolean, crossRepoRoots: Record<string, string> }}
30
35
  */
31
36
  export function readDocsCheckConfig(projectRoot) {
32
- const fallback = { paths: null, skip: [], keywordAssert: true }
37
+ const fallback = { paths: null, skip: [], keywordAssert: true, crossRepoRoots: {} }
33
38
  try {
34
39
  const p = join(projectRoot, '.sillyspec', 'local.yaml')
35
40
  if (!existsSync(p)) return fallback
@@ -37,10 +42,17 @@ export function readDocsCheckConfig(projectRoot) {
37
42
  if (!doc || typeof doc !== 'object') return fallback
38
43
  const dc = doc['docs-check']
39
44
  if (!dc || typeof dc !== 'object') return fallback
45
+ const crr = dc['cross_repo_roots']
40
46
  return {
41
47
  paths: Array.isArray(dc.paths) ? dc.paths.filter(s => typeof s === 'string') : null,
42
48
  skip: Array.isArray(dc.skip) ? dc.skip.filter(s => typeof s === 'string') : [],
43
49
  keywordAssert: typeof dc.keywordAssert === 'boolean' ? dc.keywordAssert : true,
50
+ // repo://<name> → 本机仓库根(每台设备各自配;local.yaml 是 gitignored,绝对路径不入库)
51
+ crossRepoRoots: crr && typeof crr === 'object' && !Array.isArray(crr)
52
+ ? Object.fromEntries(
53
+ Object.entries(crr).filter(([, v]) => typeof v === 'string' && v.trim() !== ''),
54
+ )
55
+ : {},
44
56
  }
45
57
  } catch {
46
58
  return fallback
@@ -63,9 +75,10 @@ export function collectDocRefs(md) {
63
75
  while ((m = re.exec(md)) !== null) {
64
76
  refs.push({
65
77
  ref: m[0],
66
- file: m[1],
67
- start: parseInt(m[2], 10),
68
- end: m[3] !== undefined ? parseInt(m[3], 10) : parseInt(m[2], 10),
78
+ repo: m[1] !== undefined ? m[1] : null,
79
+ file: m[2],
80
+ start: parseInt(m[3], 10),
81
+ end: m[4] !== undefined ? parseInt(m[4], 10) : parseInt(m[3], 10),
69
82
  docLine: md.slice(0, m.index).split(/\r?\n/).length,
70
83
  })
71
84
  }
@@ -268,16 +281,20 @@ function relDisplay(absPath, projectRoot) {
268
281
 
269
282
  /**
270
283
  * IO 入口:跑一次完整校验。
271
- * @param {{ projectRoot: string, docs?: string[], paths?: string[], skip?: string[], keywordAssert?: boolean }} opts
284
+ * @param {{ projectRoot: string, docs?: string[], paths?: string[], skip?: string[], keywordAssert?: boolean, crossRepoRoots?: Record<string, string> }} opts
272
285
  * projectRoot 源码仓根(候选解析与 glob 的锚点,平台模式也传源码仓根)
273
286
  * docs 显式文档相对路径列表(优先于 paths glob 展开结果)
274
- * @returns {{ ok: boolean, total: number, invalid: Array<{doc, docLine, ref, reason, suggest?: number[], fix: {fixable: boolean, newLine?: number, reason: string}}>, warnings: string[], kwChecked: number }}
287
+ * crossRepoRoots repo://<仓库名> → 本机仓库根映射(未映射的跨仓引用跳过不计失效)
288
+ * @returns {{ ok: boolean, total: number, invalid: Array<{doc, docLine, ref, reason, suggest?: number[], fix: {fixable: boolean, newLine?: number, reason: string}}>, warnings: string[], kwChecked: number, crossRepoSkipped: number }}
275
289
  * @throws {DocsCheckConfigError} glob 形态不支持
276
290
  */
277
291
  export function runDocsCheck(opts) {
278
292
  // paths/docs 显式传 null(readDocsCheckConfig 无 local.yaml 段时的回退值)须落回缺省 glob,
279
293
  // 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
280
- const { projectRoot, docs = null, paths: rawPaths = DEFAULT_DOC_PATHS, skip = [], keywordAssert = true } = opts || {}
294
+ const {
295
+ projectRoot, docs = null, paths: rawPaths = DEFAULT_DOC_PATHS, skip = [],
296
+ keywordAssert = true, crossRepoRoots = {},
297
+ } = opts || {}
281
298
  const paths = Array.isArray(rawPaths) && rawPaths.length > 0 ? rawPaths : DEFAULT_DOC_PATHS
282
299
  const warnings = []
283
300
  if (keywordAssert === false) warnings.push('关键词断言已关闭(keywordAssert=false),仅做存在性校验')
@@ -288,6 +305,8 @@ export function runDocsCheck(opts) {
288
305
  const invalid = []
289
306
  let total = 0
290
307
  let kwChecked = 0
308
+ // repo:// 跨仓引用:未配映射被跳过的数量(不计入 total/invalid,跨设备零误报)
309
+ let crossRepoSkipped = 0
291
310
 
292
311
  for (const docRel of docFiles) {
293
312
  const docAbs = join(projectRoot, docRel)
@@ -303,8 +322,32 @@ export function runDocsCheck(opts) {
303
322
  const mdLines = md.split(/\r?\n/)
304
323
 
305
324
  for (const r of refs) {
325
+ // repo:// 跨仓引用:未配映射 → 跳过(不同设备仓库位置不同,不按本仓解析误报);
326
+ // 配了映射 → 以映射根为唯一候选,走与本地引用相同的层1+层2 校验。
327
+ let candidates
328
+ if (r.repo) {
329
+ const repoRoot = crossRepoRoots && typeof crossRepoRoots[r.repo] === 'string'
330
+ ? crossRepoRoots[r.repo]
331
+ : null
332
+ if (!repoRoot) {
333
+ crossRepoSkipped++
334
+ continue
335
+ }
336
+ const abs = join(repoRoot, r.file)
337
+ if (!existsSync(abs)) {
338
+ total++
339
+ invalid.push({
340
+ doc: docRel, docLine: r.docLine, ref: r.ref,
341
+ reason: `跨仓 repo://${r.repo} → 文件不存在(cross_repo_roots 映射根:${repoRoot})`,
342
+ fix: { fixable: false, reason: '跨仓引用无法自动定位' },
343
+ })
344
+ continue
345
+ }
346
+ candidates = [abs]
347
+ } else {
348
+ candidates = resolveCandidates(projectRoot, r.file)
349
+ }
306
350
  total++
307
- const candidates = resolveCandidates(projectRoot, r.file)
308
351
  if (candidates.length === 0) {
309
352
  invalid.push({
310
353
  doc: docRel, docLine: r.docLine, ref: r.ref,
@@ -338,9 +381,11 @@ export function runDocsCheck(opts) {
338
381
  }
339
382
  if (tokens.length > 0) kwChecked++
340
383
  if (!passedAny) {
384
+ // 跨仓校验失败的 reason 加 repo:// 前缀,用户一眼区分「改外部仓引用」还是「改本仓引用」
385
+ const repoPrefix = r.repo ? `跨仓 repo://${r.repo} → ` : ''
341
386
  invalid.push({
342
387
  doc: docRel, docLine: r.docLine, ref: r.ref,
343
- reason: candidateFails.length > 1 ? `多候选全失败 → ${candidateFails.join(' | ')}` : candidateFails[0],
388
+ reason: repoPrefix + (candidateFails.length > 1 ? `多候选全失败 → ${candidateFails.join(' | ')}` : candidateFails[0]),
344
389
  // 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
345
390
  suggest: suggestLines(candidates, tokens),
346
391
  // 修复分类(--fix 判定依据):全量候选 token 命中统计,唯一命中才 fixable(D-006 保守默认)
@@ -350,7 +395,7 @@ export function runDocsCheck(opts) {
350
395
  }
351
396
  }
352
397
 
353
- return { ok: invalid.length === 0, total, invalid, warnings, kwChecked }
398
+ return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped }
354
399
  }
355
400
 
356
401
  /**
package/src/docs-debt.js CHANGED
@@ -14,7 +14,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
14
14
  import { join, relative } from 'node:path'
15
15
  import { safeGit } from './git-helper.js'
16
16
  import { parseModuleMapSimple } from './modules.js'
17
- import { runDocsCheck } from './docs-check.js'
17
+ import { runDocsCheck, readDocsCheckConfig } from './docs-check.js'
18
18
 
19
19
  /** safeGit 超时(大仓 git log 慢时降级该模块,不挂起 Wave 渲染) */
20
20
  const GIT_TIMEOUT_MS = 5000
@@ -212,7 +212,11 @@ function inlineCardInvalidRefs(entry, docGitRoot, docPathPrefix) {
212
212
  if (!docGitRoot || !docPathPrefix || !entry.doc) return null
213
213
  try {
214
214
  const docGitPath = `${docPathPrefix}/${entry.doc.replace(/^modules\//, '')}`
215
- const check = runDocsCheck({ projectRoot: docGitRoot, docs: [docGitPath] })
215
+ // HUB-06:补传 crossRepoRoots(与 docs gate 同口径),repo:// 跨仓引用不再恒跳过;
216
+ // 配置读取失败(无 local.yaml 段等)→ 不启用跨仓,与原行为一致
217
+ let crossRepoRoots
218
+ try { crossRepoRoots = readDocsCheckConfig(docGitRoot)?.crossRepoRoots } catch { crossRepoRoots = undefined }
219
+ const check = runDocsCheck({ projectRoot: docGitRoot, docs: [docGitPath], crossRepoRoots })
216
220
  if (check.ok || !Array.isArray(check.invalid) || check.invalid.length === 0) return null
217
221
  const parts = check.invalid.slice(0, 3).map((inv) => {
218
222
  const sug = Array.isArray(inv.suggest) && inv.suggest.length > 0 ? `→建议 L${inv.suggest.join('/L')}` : ''
package/src/docs-gate.js CHANGED
@@ -76,6 +76,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
76
76
  paths: checkOpts.paths || cfg.paths,
77
77
  skip: checkOpts.skip || cfg.skip,
78
78
  keywordAssert: checkOpts.keywordAssert ?? cfg.keywordAssert,
79
+ crossRepoRoots: checkOpts.crossRepoRoots || cfg.crossRepoRoots,
79
80
  });
80
81
  } catch (e) {
81
82
  if (e instanceof DocsCheckConfigError) {