sillyspec 3.28.10 → 3.28.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 (45) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/package.json +1 -1
  6. package/src/change-risk-profile.js +51 -3
  7. package/src/config-schema.js +21 -0
  8. package/src/design-facts.js +46 -9
  9. package/src/docs-check.js +14 -10
  10. package/src/docs-debt.js +7 -2
  11. package/src/docs-gate.js +42 -15
  12. package/src/endpoint-extractor.js +50 -2
  13. package/src/foreign-declared.js +25 -16
  14. package/src/hooks/worktree-guard.js +75 -2
  15. package/src/index.js +129 -1
  16. package/src/machine-interface.js +26 -0
  17. package/src/probe7-anchor-check.js +20 -2
  18. package/src/progress/stage-machine.js +34 -5
  19. package/src/progress.js +1 -1
  20. package/src/quicklog.js +231 -1
  21. package/src/run/complete-handlers.js +172 -1
  22. package/src/run/complete.js +73 -3
  23. package/src/run/gate-snapshot.js +262 -0
  24. package/src/run/gates.js +41 -7
  25. package/src/run/prompt.js +79 -1
  26. package/src/run/shared.js +10 -0
  27. package/src/stage-contract-spec.js +14 -0
  28. package/src/stage-contract.js +314 -8
  29. package/src/stage-review-checklist.js +3 -1
  30. package/src/stage-review.js +137 -2
  31. package/src/stages/archive.js +9 -5
  32. package/src/stages/brainstorm.js +7 -1
  33. package/src/stages/execute.js +62 -8
  34. package/src/stages/plan-postcheck.js +42 -5
  35. package/src/stages/plan.js +8 -1
  36. package/src/stages/quick.js +5 -5
  37. package/src/stages/verify.js +3 -2
  38. package/src/sync.js +69 -0
  39. package/src/task-review.js +9 -3
  40. package/src/verify-facts-schema.js +78 -9
  41. package/src/verify-postcheck.js +260 -26
  42. package/src/verify-probes.js +780 -44
  43. package/src/worktree-apply.js +175 -3
  44. package/src/write-audit.js +55 -0
  45. package/.claude/full.json +0 -1
@@ -301,6 +301,33 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
301
301
  return { level, triggers, suppressedTriggers, requiredVerification }
302
302
  }
303
303
 
304
+ // ============ 回执来源分类打标(X-10 / D-002 / D-006,2026-09-17-pass-cap-semantics task-02) ============
305
+ //
306
+ // EHS 实证(D-002):mvn compile + JUnitCore 纯单测的三条 log 通过了集成回执四条件校验——
307
+ // 单测/编译类回执被当成集成实测。打标口径:只认回执 command 的命令来源声明(quality-scan
308
+ // 记录的命令与 verify_precedents 声明同思想),不解析日志内容猜测(X-10);未定类默认
309
+ // 'build'(fail-closed 侧:build/unit 回执不构成「集成实测已跑」绿判据,宁可触发封顶要求
310
+ // handover——stage-contract 侧 validatePassEligibility(evaluatePassEligibility 纯函数 +
311
+ // resolveFactsExpected 判定)消费本口径对结论=PASS 封顶;verify-probes producer 侧
312
+ // classifyReceiptCommandSource(facts.integrationRan 判定)与本分类同族口径,两处正则族
313
+ // 保持同步演进)。smoke 冒烟命令族计 cross-layer(D-006 退役判据:批次 C commands.smoke
314
+ // 落地后由 smoke 回执一票判定「集成实测已跑」——起服务冒烟即跨层实测,不得默认 build 误拦金路径)。
315
+ const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
316
+ '\\bcurl\\b', '\\bwget\\b', '\\bhttpie\\b', '\\bInvoke-WebRequest\\b', '\\bInvoke-RestMethod\\b',
317
+ '\\biwr\\b', '\\birm\\b', 'https?://',
318
+ 'spring-boot:run', '\\bjava\\b[^|\\n]*\\s-jar\\b',
319
+ '\\b(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:dev|start|serve|smoke)\\b',
320
+ '\\bdotnet\\s+run\\b', '\\bflask\\s+run\\b', '\\buvicorn\\b', '\\bgunicorn\\b',
321
+ '\\b(?:nc|netcat|telnet|socat)\\b',
322
+ ].join('|'), 'i')
323
+ const RECEIPT_SOURCE_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
324
+ function classifyReceiptSourceTag(command) {
325
+ const cmd = String(command || '')
326
+ if (RECEIPT_SOURCE_CROSS_LAYER_RE.test(cmd)) return 'cross-layer'
327
+ if (RECEIPT_SOURCE_UNIT_RE.test(cmd)) return 'unit'
328
+ return 'build'
329
+ }
330
+
304
331
  /**
305
332
  * 检查 verify-result.md 是否包含集成验证证据
306
333
  *
@@ -308,6 +335,10 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
308
335
  * CLI 结构化回执文本(如 verify-services.receipt.json 的服务回收回执)——它随 verifyContent
309
336
  * 一起参与 literals 匹配,agent 真实起过服务且 CLI 回收过(有回执)时不再依赖其自然语言
310
337
  * 措辞恰好含字面词,表述差异不再误拦。
338
+ *
339
+ * opts.sourceTag(X-10 / D-006,2026-09-17-pass-cap-semantics task-02):可选回执来源声明
340
+ * ('cross-layer' | 'build' | 'unit'),透传给 auditRuntimeReceipt 优先于按 command 就地
341
+ * 分类;非法值忽略。build/unit 回执不作集成实测绿判据(缺省按 command 分类,未定类 build)。
311
342
  */
312
343
  export function checkIntegrationEvidence(verifyContent, requiredVerification, opts = {}) {
313
344
  const errors = []
@@ -333,7 +364,7 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
333
364
  const hasGreen = greenReceipts.length > 0
334
365
  if (needsIntegration && !hasGreen) {
335
366
  const bad = receiptAudit.filter(a => !a.green)
336
- errors.push(`integration-critical 变更无绿回执(${bad.length} 条回执校验不过:${bad.map(a => a.reason).join(';') || '无有效回执'})——绿判据:log 存在 × mtime ∈ verify 窗口 × 日志无失败签名 × exit 0`)
367
+ errors.push(`integration-critical 变更无绿回执(${bad.length} 条回执校验不过:${bad.map(a => a.reason).join(';') || '无有效回执'})——绿判据:log 存在 × mtime ∈ verify 窗口 × 日志无失败签名 × exit 0 × 来源 cross-layer(build/unit 回执不作集成实测判据,D-006)`)
337
368
  }
338
369
  if (needsLogEvidence && !hasGreen) {
339
370
  errors.push('integration-critical 变更的回执未通过一致性校验(Runtime Evidence 等价物)')
@@ -394,9 +425,19 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
394
425
  * 单条回执一致性校验(FR-04 绿判据)。噪声剔除:签名行首匹配 + 剔除「0 errors」类良性行
395
426
  * (本仓 verify-postcheck.js 测试输出解析先例同款)。fail-soft:校验依赖缺失(opts 无
396
427
  * cwd/verifyStartAt)时只核 exitCode 与日志可读性,不假装跑了文件校验。
428
+ *
429
+ * opts.sourceTag(X-10 / D-006,2026-09-17-pass-cap-semantics task-02):可选回执来源声明,
430
+ * 缺省按回执 command 来源就地分类(classifyReceiptSourceTag),未定类默认 'build'(向后
431
+ * 兼容的 fail-closed 缺省);sourceTag ∈ {build, unit} 的回执不作集成实测绿判据(四条件
432
+ * 全过也只到 non-green,reason 点名来源)——verify-postcheck 不在本调用链上,不受影响。
397
433
  */
398
434
  function auditRuntimeReceipt(r, opts) {
399
- const out = { claim: r && r.claim, logPath: r && r.logPath, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
435
+ const out = { claim: r && r.claim, logPath: r && r.logPath, sourceTag: null, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
436
+ // 打标依据 = 命令来源声明(不解析日志内容,X-10):opts.sourceTag 调用方声明源优先,
437
+ // 否则按回执 command 分类;非法/缺省走分类,未定类默认 build。
438
+ const declaredSource = opts && typeof opts.sourceTag === 'string'
439
+ && ['cross-layer', 'build', 'unit'].includes(opts.sourceTag) ? opts.sourceTag : null
440
+ out.sourceTag = declaredSource || classifyReceiptSourceTag(r && r.command)
400
441
  if (!r || !r.logPath) { out.reason = '回执缺 logPath'; return out }
401
442
  let content = null
402
443
  try {
@@ -424,7 +465,14 @@ function auditRuntimeReceipt(r, opts) {
424
465
  }
425
466
  if (out.failSignatures > 0) { out.reason = `日志含 ${out.failSignatures} 行失败签名(行首 error/exception/traceback/fatal,已剔除良性计数行)`; return out }
426
467
  if (out.exitCode !== 0) { out.reason = `exitCode=${out.exitCode}(非 0 不可用作在场证据)`; return out }
468
+ // 来源门(D-002/D-006,2026-09-17-pass-cap-semantics task-02):四条件全过但来源 build/unit
469
+ // (编译/单测/构建类命令)→ 不作集成实测绿判据(EHS 实证口径),排在前置硬伤(缺日志/
470
+ // 出窗/签名/exit≠0)之后不抢 reason 优先级。
471
+ if (out.sourceTag !== 'cross-layer') {
472
+ out.reason = `回执来源 ${out.sourceTag}(command:${(r && r.command) || ''})不构成「集成实测已跑」——build/unit 类命令(编译/单测/构建)不作集成绿判据(D-006)。出路:提供跨层实测回执(起服务/HTTP/进程对进程/smoke 冒烟命令),或降级 PASS WITH NOTES 并在「## 移交项(结构化)」承载缺口`
473
+ return out
474
+ }
427
475
  out.green = true
428
- out.reason = '绿回执(log 存在 × mtime 窗口 × 无失败签名 × exit 0)'
476
+ out.reason = '绿回执(log 存在 × mtime 窗口 × 无失败签名 × exit 0 × 来源 cross-layer)'
429
477
  return out
430
478
  }
@@ -152,6 +152,15 @@ export const LOCAL_YAML_SCHEMA = {
152
152
  { path: 'worktree.supplyFiles', type: 'array', optional: true, status: 'live', readers: ['readSupplyFilesConfig + _supplyGeneratedFiles (src/worktree.js — create step 5.9 供给步)'], desc: '随 worktree create 从主仓复制的生成物清单:精确路径或 glob(* 单层 / ** 多层),相对仓根。展开上限 200 文件超出截断警告;实供清单记 meta.supplyFiles;缺省 [] = 供给步空转零行为变化。', example: 'src/build-id.ts' },
153
153
  ],
154
154
  },
155
+ {
156
+ id: 'gate_snapshot',
157
+ title: '门禁隔离快照 copy 面',
158
+ note: '门禁快照(gate-snapshot)= HEAD worktree + 会话文件 overlay + 环境目录 junction——gitignored 生成物(api-types/generated 类)不进 HEAD 也不在会话集,快照内 lint/test 环境性假败(2026-09-16 驾驭小结④,friction5-hardening R4 / D-002@v1)。按本清单在快照构建期从主仓 junction 链接补齐(失败回退复制);快照内 overlay 已覆盖的同名路径跳过(本变更最新态优先)。⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。',
159
+ keys: [
160
+ { path: 'gate_snapshot.copy', type: 'array', optional: true, status: 'live', readers: ['createGateSnapshot (src/run/gate-snapshot.js)'], desc: '门禁隔离快照的生成物/额外路径 copy 面(元素 string,相对仓根):HEAD 快照缺 gitignored 生成物导致 lint/test 环境性假败;声明后快照构建期从主仓 junction 链接(失败回退复制)。⚠️ junction 是活链接,快照内再跑生成命令会写穿到主仓该目录。未配置/空清单 = 全段空转零行为变化。', example: 'gate_snapshot:\n copy:\n - src/generated' },
161
+ { path: 'gate_snapshot.commands', type: 'array', optional: true, status: 'live', readers: ['parseGateSnapshotCommands + runGateSnapshotCommands (src/run/gate-snapshot.js — createGateSnapshot 环境预检后 copy 面前)'], desc: '门禁隔离快照的命令面(元素 string):快照构建期在环境目录链接后、copy 面前于快照根逐条执行(cwd=快照根、300s/条超时帽、非零退出/超时只 warn 继续——fail-open 不作废快照),产出「本仓应然态」生成物,收口主仓生成物缺失/过期(fresh clone 未跑 postinstall / build-id 类产物)时快照内全量 lint/test 必挂的坑(D-002@v2,2026-09-17-feedback-hardening FR-01)。⚠️ 环境目录(node_modules/venv)是活链接,命令写它们会穿透主仓——只放生成物命令(npm run gen:build-id 类),不放 install/build 全家桶。未配置 = 全段空转零行为。', example: 'npm run gen:build-id' },
162
+ ],
163
+ },
155
164
  {
156
165
  id: 'auto_mode',
157
166
  title: '变更规模自动分类',
@@ -379,6 +388,18 @@ worktree-hook:
379
388
  # supplyFiles:
380
389
  # - src/build-id.ts
381
390
 
391
+ # ── 门禁隔离快照 copy 面(HEAD 快照缺 gitignored 生成物 → 快照内 lint/test 环境性假败;构建期从主仓 junction 补齐)──
392
+ # 相对仓根路径列表;失败回退复制;快照内 overlay 已覆盖的同名路径跳过。
393
+ # ⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。
394
+ # commands 面(D-002@v2):快照构建期在快照根逐条执行的生成物命令(postinstall/build-id 类,
395
+ # 产出本仓应然态;300s/条超时帽,失败 warn 继续)。⚠️ 命令不得改写 node_modules/venv 等
396
+ # 环境目录(活链接,写穿透主仓)——只放生成物命令。
397
+ # gate_snapshot:
398
+ # copy:
399
+ # - src/generated
400
+ # commands:
401
+ # - npm run gen:build-id
402
+
382
403
  # ── 变更规模自动分类(sillyspec run auto 时按需求描述强制 quick/full;正则数组,i 大小写无关)──
383
404
  auto_mode:
384
405
  force_full_patterns:
@@ -332,7 +332,8 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
332
332
  lines.push('')
333
333
  lines.push('| 操作 | 文件路径 | 说明 |')
334
334
  lines.push('|---|---|---|')
335
- lines.push('<!-- 示例行(替换为实际清单后删除本注释):| 新增 | src/xxx/NewFile.java | 说明(含对外字段时交代 producer→consumer 数据流) | -->')
335
+ lines.push('<!-- 示例行(替换为实际清单后删除本注释):| 新增 | NEW:src/xxx/NewFile.java | 说明(含对外字段时交代 producer→consumer 数据流) | -->')
336
+ lines.push('<!-- 路径存在性核验铁律:计划新建的文件路径必须带 NEW: 前缀(如上例),已有文件写仓根相对正斜杠路径——末步 --done 门禁逐一核验存在性,无前缀的新建路径按 design_file_ref_invalid 拦截返工(2026-09-17 用户反馈⑥-①,写作期即提示) -->')
336
337
  lines.push('')
337
338
 
338
339
  lines.push('## 接口定义')
@@ -458,22 +459,58 @@ export function validateDesignFileList({ changeDir, cwd } = {}) {
458
459
  warnings.push(`design 清单疑似仓变更段头格式异常(该段文件可能被记到上一段仓,覆盖对账会点名):${h}`)
459
460
  }
460
461
 
462
+ // 跨仓注册表读侧口径 = <cwd>/.sillyspec/local.yaml(与 execute MultiRepoContext /
463
+ // register-repo 写侧一致,parseRepoRegistry 自带 CRLF 容差)——有段头/无段头两分支
464
+ // 共用(task-05 / FR-10 上移:无段头分支也要做跨仓注册路径前缀命中判定)
465
+ let registry = null
466
+ const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
467
+ if (existsSync(localYamlPath)) {
468
+ try { registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8')) } catch { registry = null }
469
+ }
470
+
461
471
  if (!hasSegmentHeader) {
462
472
  const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
463
473
  if (!Array.isArray(entries) || entries.length === 0) {
464
474
  return { ok: true, errors, warnings: [...warnings, 'design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
465
475
  }
466
- checkAgainstRoot(cwd, entries.map(e => e.path), '主仓')
476
+ // task-05 / FR-10 无段头缺口:清单行含跨仓注册路径(注册值/basename 前缀命中)时不按
477
+ // 主仓根打 design_file_ref_invalid 逼 NEW: 前缀(跨仓既有文件被逼声明计划新建=语义撒谎,
478
+ // 坑 design-file-ref-cross-repo-blind 同族的无段头形态),降 warning 提示补段头——补
479
+ // 「## <repo> 仓变更」段头后该段按仓根核验,真幻觉路径仍会在仓根核验下暴露(只消除
480
+ // 假红、不引入假绿)。纯主仓行(无前缀命中)走原主仓根核验,行为零变化。
481
+ const crossPrefixes = []
482
+ if (registry) {
483
+ for (const [repoKey, rootRaw] of registry) {
484
+ const root = String(rootRaw || '').replace(/\\/g, '/').replace(/\/+$/, '')
485
+ if (!root) continue
486
+ const bn = root.split('/').pop()
487
+ crossPrefixes.push({ repoKey, prefixes: bn && bn !== root ? [root, bn] : [root] })
488
+ }
489
+ }
490
+ const hitCrossRepo = (rawPath) => {
491
+ const n = String(rawPath || '').trim().replace(/\\/g, '/')
492
+ if (!n) return null
493
+ for (const c of crossPrefixes) {
494
+ for (const pre of c.prefixes) {
495
+ if (n === pre || n.startsWith(pre + '/')) return c.repoKey
496
+ }
497
+ }
498
+ return null
499
+ }
500
+ const mainPaths = []
501
+ for (const e of entries) {
502
+ const hit = hitCrossRepo(e.path)
503
+ if (hit) {
504
+ warnings.push(`design 清单无仓变更段头且该行疑似跨仓 ${hit} 仓路径:${e.path}——补「## ${hit} 仓变更」段头后按该仓根核验(暂不按主仓根核验/逼 NEW: 前缀)`)
505
+ } else {
506
+ mainPaths.push(e.path)
507
+ }
508
+ }
509
+ checkAgainstRoot(cwd, mainPaths, '主仓')
467
510
  return { ok: errors.length === 0, errors, warnings }
468
511
  }
469
512
 
470
- // 有段头:逐段按其仓根核验。跨仓注册表读侧口径 = <cwd>/.sillyspec/local.yaml
471
- // (与 execute MultiRepoContext / register-repo 写侧一致,parseRepoRegistry 自带 CRLF 容差)
472
- let registry = null
473
- const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
474
- if (existsSync(localYamlPath)) {
475
- try { registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8')) } catch { registry = null }
476
- }
513
+ // 有段头:逐段按其仓根核验。
477
514
  for (const [repo, paths] of byRepo) {
478
515
  if (repo === 'main') {
479
516
  checkAgainstRoot(cwd, paths, '主仓')
package/src/docs-check.js CHANGED
@@ -117,12 +117,15 @@ export function createHeadReader(projectRoot, ref = 'HEAD') {
117
117
  * 纯位置锚 `file.js:123?`(2026-09-11 用户实证 docs gate 误伤:跨文件引用+反话论述——锚 A
118
118
  * 文件而行内反引号 token 全是 B 概念,层 2 必失败,被迫删行号绕开):`?` 显式声明本锚只做
119
119
  * 定位不做关键词断言,层 1(存在性+行界)照校——论述语境锚的一等语法,替代行号化绕开。
120
- * 文件段展开循环形(D-006,2026-09-08-docs-fix-capability):普通段可选 + 零或多个「闭合括号段+普通段」
121
- * 迭代——支持 Next.js 路由组 (dashboard) 等括号路径;markdown 链接 [t](foo.js:12) 在 `(` 处括号段
122
- * 要求 `)` 先于 `:` 闭合而失败,回落 foo.js:12 零回归;嵌套 ((x)) 部分提取(与旧行为一致)。
123
- * ⚠ 不用原子序列 (?:A+|B+)+——经典嵌套量词 ReDoS(Grill 实证 n=30 长 token 73.8s),
124
- * 展开循环每次迭代必含括号段 → 划分唯一 → 线性。 */
125
- const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:\([A-Za-z0-9_.\-\/]+\)[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go)):(\d+)(?:-(\d+))?(\?)?/g
120
+ * 文件段展开循环形(D-006,2026-09-08-docs-fix-capability;圆/方并列 2026-09-17-docs-bracket-reanchor):
121
+ * 普通段可选 + 零或多个「闭合括号段(圆/方同权)+普通段」迭代——Next.js 路由组 (dashboard) 与
122
+ * 动态路由 [id]/[cid] 一等公民(D-001:完整开闭同形括号对并列,[ ] 不进普通段字符类);markdown
123
+ * 链接 [t](foo.js:12) 的 [t] 匹配方括号段后遇 `(` 截止——后续圆括号段要求 `)` 先于 `:` 闭合、
124
+ * 扩展名组要求 `.` 均失败,回溯放弃 [t] 段回落提取 foo.js:12(与圆括号段同族回落,零回归);嵌套
125
+ * ((x)) 与 [[x]]/[[...slug]] 内容含 `(`/`[` 不属括号段字符类 → 段不成立,部分提取残段(与旧正则
126
+ * 逐字节一致)。⚠ 不用原子序列 (?:A+|B+)+——经典嵌套量词 ReDoS(Grill 实证 n=30 长 token 73.8s),
127
+ * 展开循环每次迭代必含完整开闭同形括号对 → 划分唯一 → 线性。 */
128
+ const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:(?:\([A-Za-z0-9_.\-\/]+\)|\[[A-Za-z0-9_.\-\/]+\])[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go)):(\d+)(?:-(\d+))?(\?)?/g
126
129
 
127
130
  /** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八)
128
131
  * + .sillyspec/changes/ + .sillyspec/knowledge/(P1-1,noai-ir-roadmap §4:最活跃、最易产生
@@ -167,11 +170,12 @@ export class DocsCheckConfigError extends Error {}
167
170
  /**
168
171
  * 符号锚正则(P1-2,noai-ir-roadmap §4):`file.js::symbol` 形态——校验时解析符号定义行,
169
172
  * 天然免疫行号漂移(重锚从「每次代码移动」降为「仅改名时」)。文件段与 REF_RE 同款(含
170
- * repo:// 跨仓前缀与括号路径组展开),`::` 后只认 ASCII 标识符(自然语言/中文括注不触发,
171
- * QUICKLOG file-notes 的 `path::注` 约定因注解非标识符而天然互斥)。与 REF_RE 互斥:
172
- * `::ident` 无数字不匹配行号式;`file.js:12` 无 `::` 不匹配符号式。
173
+ * repo:// 跨仓前缀与括号路径组展开;2026-09-17-docs-bracket-reanchor 起圆/方括号段并列
174
+ * 同权——动态路由 app/[lang]/layout.tsx 类路径可符号锚),`::` 后只认 ASCII 标识符(自然语言/
175
+ * 中文括注不触发,QUICKLOG file-notes 的 `path::注` 约定因注解非标识符而天然互斥)。
176
+ * 与 REF_RE 互斥:`::ident` 无数字不匹配行号式;`file.js:12` 无 `::` 不匹配符号式。
173
177
  */
174
- const SYMBOL_REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:\([A-Za-z0-9_.\-\/]+\)[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go))::([A-Za-z_$][A-Za-z0-9_$]*)/g
178
+ const SYMBOL_REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:(?:\([A-Za-z0-9_.\-\/]+\)|\[[A-Za-z0-9_.\-\/]+\])[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go))::([A-Za-z_$][A-Za-z0-9_$]*)/g
175
179
 
176
180
  /**
177
181
  * 符号定义行定位(与 classifyFix 的 defRe 同款形态,单一判定口径):function/const/let/
package/src/docs-debt.js CHANGED
@@ -363,8 +363,13 @@ export function renderDecisionTouchFacts(touches) {
363
363
  * @returns {{ root: string, changedFiles: string[] }} root = 实际 git 锚(computeDocsDebt 的 projectRoot 同值)
364
364
  */
365
365
  export function collectExecuteChangedFiles({ specBase, changeName, cwd, projectRoot } = {}) {
366
- const wtRoot = specBase ? join(specBase, '.runtime', 'worktrees', changeName || '') : null
367
- const root = projectRoot || (wtRoot && changeName && existsSync(wtRoot) ? wtRoot : cwd) || process.cwd()
366
+ // 双候选 worktree 根(坑 platform-dual-root-fourth-consumer,2026-09-16 hunt 实证):平台模式
367
+ // specBase=specRoot 时主仓 worktree 在项目侧 <cwd>/.sillyspec——单查 specBase 必落空 → root 退
368
+ // cwd → 收进的是主仓脏集而非 worktree 交付集,docs-debt 事实面错。对齐 contract-matrix 双候选。
369
+ const wtCandidates = [...new Set([specBase, join(cwd || process.cwd(), '.sillyspec')].filter(Boolean))]
370
+ .map(b => join(b, '.runtime', 'worktrees', changeName || ''))
371
+ const wtRoot = changeName ? (wtCandidates.find(p => existsSync(p)) || null) : null
372
+ const root = projectRoot || (wtRoot ? wtRoot : cwd) || process.cwd()
368
373
  const changed = new Set()
369
374
  const statusRes = safeGit(root, ['status', '--porcelain'], { trim: false })
370
375
  if (!statusRes.error && statusRes.value) {
package/src/docs-gate.js CHANGED
@@ -16,8 +16,16 @@
16
16
  * 远端 origin/main 已合入的失效消化/新增不会回流本地基线——出现「基线 371 < origin/main
17
17
  * 实测 379、本地 current 379」时 ratchet 拦 379>371,但本次推送零增量(不劣于远端)——
18
18
  * ratchet 本质=拦增量,被陈旧基线破坏成拦存量。修复:current > baseline 分支先实测
19
- * origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤ 实测值即放行 + 提示
20
- * 重锚;实测失败/无远端 ref fail-open 回原拦。快路径(current ≤ baseline)零成本零变化。
19
+ * origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤ 实测值即放行;实测
20
+ * 失败/无远端 ref fail-open 回原拦。快路径(current ≤ baseline)零成本零变化。
21
+ *
22
+ * 陈旧分支自动重锚(2026-09-17-docs-bracket-reanchor,D-002@v1):走到该分支即已实付
23
+ * 远端实测成本验证「本次不劣于远端」——已验证事实自动回流基线:口径守卫通过即
24
+ * writeBaseline(current) + 消息披露(陈旧提示出现一次即消失,棘轮只紧不松:新基线 =
25
+ * current ≤ 远端实测,每一分增量都有实测背书)。守卫只拦 checkOpts 一次性覆盖(paths/
26
+ * skip/keywordAssert/crossRepoRoots 四键——异口径计数写盘会错调基线),不拦 local.yaml
27
+ * 持久口径(measure 与 current 恒同读该配置,读写同源自洽);首次立线 fail-closed 不变
28
+ * (无基线仍须显式 --init-baseline,自动重锚仅限基线已存在的陈旧分支)。
21
29
  */
22
30
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
23
31
  import { join } from 'node:path';
@@ -138,12 +146,19 @@ export async function measureRemoteBaselineCount(projectRoot, remoteRefs = ['ori
138
146
  * IO 入口:跑一次 gate。
139
147
  * @param {{ projectRoot: string, specBase: string, initBaseline?: boolean }} opts
140
148
  * projectRoot 源码仓根(docs check 锚);specBase .sillyspec 根(基线文件所在)
141
- * @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert 覆盖;缺省读 local.yaml)
149
+ * @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert/crossRepoRoots 覆盖;缺省
150
+ * 读 local.yaml)——四键任一显式传入即触发陈旧分支口径守卫(不自动重锚,见头注)
142
151
  * @returns {Promise<{ exitCode: 0|1|2, ok: boolean, current: number, baseline: number|null,
143
- * delta: number|null, message: string, inited: boolean }>}
152
+ * delta: number|null, message: string, inited: boolean,
153
+ * reanchored: boolean }>} reanchored:陈旧分支自动重锚 true(baseline 返
154
+ * 新值 = current),其余分支缺省 false
144
155
  */
145
156
  export async function runDocsGate(opts = {}, checkOpts = {}) {
146
157
  const { projectRoot, specBase, initBaseline = false } = opts;
158
+ // 口径守卫:checkOpts 一次性子集/异口径覆盖时计数口径 ≠ 持久口径,写盘会错调基线——
159
+ // 陈旧分支不自动重锚(against 不算守卫键:与 --init-baseline --against 同写提交树
160
+ // 实测值语义一致,不新增口径)。不拦 local.yaml 持久口径(measure 与 current 恒同读)
161
+ const scopeGuarded = !!(checkOpts.paths || checkOpts.skip || checkOpts.keywordAssert != null || checkOpts.crossRepoRoots);
147
162
  let result;
148
163
  try {
149
164
  const cfg = readDocsCheckConfig(projectRoot);
@@ -159,7 +174,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
159
174
  });
160
175
  } catch (e) {
161
176
  if (e instanceof DocsCheckConfigError) {
162
- return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false };
177
+ return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false, reanchored: false };
163
178
  }
164
179
  throw e;
165
180
  }
@@ -167,7 +182,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
167
182
 
168
183
  if (initBaseline) {
169
184
  writeBaseline(specBase, current);
170
- return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
185
+ return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true, reanchored: false };
171
186
  }
172
187
 
173
188
  const baseline = readBaseline(specBase);
@@ -175,27 +190,39 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
175
190
  return {
176
191
  exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null,
177
192
  message: `❌ docs gate: 无基线文件(${baselineDisplay(specBase)})。首次使用先跑 sillyspec docs gate --init-baseline(以当前实测数立基线,存量既往不咎只拦增量)`,
178
- inited: false,
193
+ inited: false, reanchored: false,
179
194
  };
180
195
  }
181
196
  if (Number.isNaN(baseline)) {
182
- return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
197
+ return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false, reanchored: false };
183
198
  }
184
199
  const v = evaluateRatchet({ current, baseline });
185
200
  if (v.ok) {
186
201
  // 快路径(current ≤ baseline):原路零变化——不触远端实测(零 git 成本零行为漂移)
187
- return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false };
202
+ return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false, reanchored: false };
188
203
  }
189
204
  // 坑 docs-gate-stale-baseline(ql-20260915-004):current > baseline 时 origin 实测兜底——
190
205
  // ratchet 本质=拦增量;基线是静态快照,远端已合入的失效增长不回流基线会造成「未劣于远端
191
- // 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 + 提示
192
- // 重锚;无远端 ref / 实测失败 fail-open 回原拦;current > 实测值 = 真增量劣于远端 → 拦。
206
+ // 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 + 自动
207
+ // 重锚落盘(D-002@v1;口径守卫命中时不写盘、维持手动重锚建议);无远端 ref / 实测失败
208
+ // fail-open 回原拦;current > 实测值 = 真增量劣于远端 → 拦。
193
209
  const measured = await measureRemoteBaselineCount(projectRoot);
194
210
  if (measured.originCount !== null && current <= measured.originCount) {
211
+ if (scopeGuarded) {
212
+ // 守卫:checkOpts 一次性覆盖(异口径计数)——基线文件不动,维持手动 --init-baseline 建议文案
213
+ return {
214
+ exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
215
+ message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——建议 sillyspec docs gate --init-baseline 重锚锁定(以当前实测 ${current} 立线,存量既往不咎只拦增量)`,
216
+ inited: false, reanchored: false,
217
+ };
218
+ }
219
+ // 自动重锚:已实付实测成本验证「本次不劣于远端」——事实回流基线,同态第二次跑走快路径
220
+ //(陈旧提示与远端实测各只发生一次);新基线 = current ≤ 远端实测,棘轮只紧不松
221
+ writeBaseline(specBase, current);
195
222
  return {
196
- exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
197
- message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——建议 sillyspec docs gate --init-baseline 重锚锁定(以当前实测 ${current} 立线,存量既往不咎只拦增量)`,
198
- inited: false,
223
+ exitCode: 0, ok: true, current, baseline: current, delta: v.delta, originCount: measured.originCount,
224
+ message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——📌 已自动重锚 基线 ${baseline}→${current}(实测不劣于远端,已落盘锁定;棘轮只紧不松)`,
225
+ inited: false, reanchored: true,
199
226
  };
200
227
  }
201
228
  const remoteNote = measured.originCount !== null
@@ -204,6 +231,6 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
204
231
  return {
205
232
  exitCode: 1, ok: false, current, baseline, delta: v.delta, originCount: measured.originCount,
206
233
  message: `❌ docs gate: ${current} 处失效 > 基线 ${baseline}(新增 ${v.delta} 处)${remoteNote},拦截。修掉新增引用或显式 --init-baseline 重置基线(需你确认存量合法)`,
207
- inited: false,
234
+ inited: false, reanchored: false,
208
235
  };
209
236
  }
@@ -24,6 +24,46 @@ function lineOfIndex(content, index) {
24
24
  return content.slice(0, index).split('\n').length
25
25
  }
26
26
 
27
+ /**
28
+ * 注释掩码(坑 endpoint-extractor-selfdoc-noise,2026-09-16 E 变更 verify 实证):JS/Java 的
29
+ * // 与 /* *\/ 注释区替换为等长空格(换行保留)——注释里的 `app.use("/api", router)`、
30
+ * `router.get("/api/xxx")`、`@GetMapping("/x")` 文档示例不再被全文正则当真路由(探针5 曾报
31
+ * 「GET /api/path、GET /api unused」假 advisory)。字符串字面量(' " `)原样保留(真路由在
32
+ * 字符串里);正则字面量按字符串近似处理(端点正则里出现路由字符串形态极罕见,误掩概率低)。
33
+ * 保长度 = 索引/行号零漂移,lineOfIndex 与全部既有正则兼容。
34
+ * @param {string} content
35
+ * @returns {string} 等长掩码后内容
36
+ */
37
+ function stripCommentsKeepLength(content) {
38
+ const src = String(content || '')
39
+ const out = src.split('')
40
+ let st = 'normal' // normal | block | sq | dq | tick
41
+ for (let i = 0; i < src.length; i++) {
42
+ const c = src[i], n = src[i + 1]
43
+ if (st === 'normal') {
44
+ if (c === '/' && n === '/') {
45
+ // 行注释:掩到行尾(保留换行符本身)
46
+ let j = i
47
+ while (j < src.length && src[j] !== '\n') { out[j] = ' '; j++ }
48
+ i = j - 1
49
+ } else if (c === '/' && n === '*') {
50
+ out[i] = ' '; out[i + 1] = ' '
51
+ st = 'block'; i++
52
+ } else if (c === "'") st = 'sq'
53
+ else if (c === '"') st = 'dq'
54
+ else if (c === '`') st = 'tick'
55
+ } else if (st === 'block') {
56
+ if (c === '*' && n === '/') { out[i] = ' '; out[i + 1] = ' '; st = 'normal'; i++ }
57
+ else if (c !== '\n') out[i] = ' '
58
+ } else {
59
+ // 字符串内:转义跳过;闭合引号回 normal(内容原样保留)
60
+ if (c === '\\') i++
61
+ else if ((st === 'sq' && c === "'") || (st === 'dq' && c === '"') || (st === 'tick' && c === '`')) st = 'normal'
62
+ }
63
+ }
64
+ return out.join('')
65
+ }
66
+
27
67
  /**
28
68
  * 从单个文件提取 FastAPI router 端点
29
69
  * 支持 APIRouter(prefix=...) 和 @router.get/post/put/delete/patch("/path")
@@ -73,7 +113,13 @@ export function extractFastApiEndpoints(filePath) {
73
113
  * @returns {Array<{ method: string, path: string, source: string, line: number }>}
74
114
  */
75
115
  export function extractExpressEndpoints(filePath) {
76
- const content = readFileSync(filePath, 'utf8')
116
+ const rawContent = readFileSync(filePath, 'utf8')
117
+ // 注释掩码(坑 endpoint-extractor-selfdoc-noise,2026-09-16 E 变更 verify 实证:本模块自身
118
+ // JSDoc 示例 `app.use("/api", router)` / `router.get("/api/xxx")` 被全文正则当真路由——
119
+ // 探针5 报「GET /api/path、GET /api unused」假 advisory,agent 逐条裁定纯耗损)。掩码保长度
120
+ // 替换注释区为空格(换行保留):字符串字面量不动(真路由在字符串里),索引/行号零漂移,
121
+ // 下游 lineOfIndex/正则全兼容。
122
+ const content = stripCommentsKeepLength(rawContent)
77
123
  const lines = content.split('\n')
78
124
  const endpoints = []
79
125
 
@@ -116,7 +162,9 @@ export function extractExpressEndpoints(filePath) {
116
162
  * @returns {Array<{ method: string, path: string, source: string, line: number }>}
117
163
  */
118
164
  export function extractSpringEndpoints(filePath) {
119
- const content = readFileSync(filePath, 'utf8')
165
+ // 注释掩码同 Express(坑 endpoint-extractor-selfdoc-noise;Java 与 JS 注释语法同族 // /* */
166
+ // ——掩码器共用。注解本体不在注释内,掩码零影响;注释里的 @GetMapping 示例不再入面)
167
+ const content = stripCommentsKeepLength(readFileSync(filePath, 'utf8'))
120
168
  const lines = content.split('\n')
121
169
  const endpoints = []
122
170
 
@@ -45,23 +45,32 @@ function getMainDirtySet(cwd) {
45
45
  * 变更 worktree 活性速查(.runtime/worktrees/<change>/meta.json)。
46
46
  * isolated=true:有存活隔离 worktree(meta 在、非 in-place、worktreePath 目录在)——
47
47
  * 其 WIP 在 worktree 内主仓 porcelain 不可见,整份 design 声明按在途处理。
48
- * @returns {Map<string, { isolated: boolean }>|null} null = 读取失败(fail-closed)
48
+ * 双根扫描(坑 platform-dual-root-fourth-consumer,2026-09-16 hunt 实证):平台模式
49
+ * specBase=specRoot 下只挂跨仓 worktree,主仓 worktree 在项目侧 <cwd>/.sillyspec——单扫
50
+ * specBase 会把主仓 worktree 变更误判 stale → 其声明失效 → 文件被误归属当前变更(③类
51
+ * 噪音/误扣)。specBase 与 cwd 项目侧两目录都扫,按变更名合并(specBase 先入)。
52
+ * @returns {Map<string, { isolated: boolean }>|null} null = 两目录均读取失败(fail-closed)
49
53
  */
50
- function loadWorktreeLiveness(specBase) {
54
+ export function loadWorktreeLiveness(specBase, cwd) {
51
55
  const map = new Map()
52
- try {
53
- const dir = join(specBase, '.runtime', 'worktrees')
54
- for (const e of readdirSync(dir, { withFileTypes: true })) {
55
- if (!e.isDirectory()) continue
56
- try {
57
- const meta = JSON.parse(readFileSync(join(dir, e.name, 'meta.json'), 'utf8'))
58
- const isolated = Boolean(meta && meta.mode !== 'in-place-fallback'
59
- && meta.worktreePath && existsSync(meta.worktreePath))
60
- map.set(meta.changeName || e.name, { isolated })
61
- } catch { /* 单条损坏跳过(该变更走主仓 dirty 判定) */ }
62
- }
63
- } catch { return null }
64
- return map
56
+ const roots = [...new Set([specBase, join(cwd || process.cwd(), '.sillyspec')].filter(Boolean))]
57
+ let anyDirOk = false
58
+ for (const base of roots) {
59
+ try {
60
+ const dir = join(base, '.runtime', 'worktrees')
61
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
62
+ if (!e.isDirectory()) continue
63
+ try {
64
+ const meta = JSON.parse(readFileSync(join(dir, e.name, 'meta.json'), 'utf8'))
65
+ const isolated = Boolean(meta && meta.mode !== 'in-place-fallback'
66
+ && meta.worktreePath && existsSync(meta.worktreePath))
67
+ map.set(meta.changeName || e.name, { isolated })
68
+ } catch { /* 单条损坏跳过(该变更走主仓 dirty 判定) */ }
69
+ }
70
+ anyDirOk = true
71
+ } catch { /* 该根不存在/不可读 → 试下一根 */ }
72
+ }
73
+ return anyDirOk ? map : null
65
74
  }
66
75
 
67
76
  /**
@@ -74,7 +83,7 @@ function loadWorktreeLiveness(specBase) {
74
83
  function filterStaleForeignDeclarations(foreign, cwd, specBase) {
75
84
  if (foreign.size === 0) return
76
85
  const dirty = getMainDirtySet(cwd)
77
- const worktrees = loadWorktreeLiveness(specBase)
86
+ const worktrees = loadWorktreeLiveness(specBase, cwd)
78
87
  if (dirty === null && worktrees === null) return // 两个事实源全失败 → 原样保留
79
88
  for (const [file, owners] of [...foreign.entries()]) {
80
89
  const live = owners.filter(owner => {
@@ -308,6 +308,8 @@ export {
308
308
  matchReadonlyWhitelist as _matchReadonlyWhitelistForTest,
309
309
  matchDangerBlacklist as _matchDangerBlacklistForTest,
310
310
  isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
311
+ analyzeCrossRepoCd as _analyzeCrossRepoCdForTest,
312
+ CROSS_REPO_TEST_RE as _CROSS_REPO_TEST_REForTest,
311
313
  };
312
314
  /**
313
315
  * 从 sillyspec.db 读取 currentStage(直读 DB,task-10 废 gate-status.json 后为唯一来源)
@@ -834,6 +836,56 @@ export function shouldBlockWrite(filePath, cwd) {
834
836
  }
835
837
  }
836
838
 
839
+ /**
840
+ * 跨仓命令锚点感知(known-issues 2026-09-16 登记「worktree 隔离期跨仓命令锚定错位」兑现):
841
+ * EHS 三仓形态(主仓 + repos 注册兄弟仓)两处摩擦——①worktree 内 `cd ../<注册仓>` 解析到
842
+ * `.sillyspec/.runtime/worktrees/` 存储目录而非真实兄弟仓(锚点错位,静默跑错地方);②
843
+ * verify 等非 execute/quick 阶段从主仓跑 `cd ../注册仓 && npx eslint` 属合法只读跨仓操作,
844
+ * 但 cd 不在只读白名单 → 整条进 stage 门禁拦截。
845
+ * 处置:①worktree cwd 本就全放行——不拦,只 stderr 纠偏留痕(真实兄弟仓路径);②从严放行
846
+ * (cd 目标命中 repos 注册根 + 其余片段全部测试/lint 类 + 危险黑名单不沾),写类/混合维持
847
+ * 原判 fail-closed。
848
+ */
849
+ const CROSS_REPO_TEST_RE = /^(?:npm (?:test|run test[\w:.-]*)|npx (?:eslint|tsc|jest|vitest|mocha|stylelint|prettier)(?:\s|$)|node --test)/i
850
+
851
+ /**
852
+ * 解析复合命令中的 cd 目标并比对 repos 注册仓根。
853
+ * @param {string} command 原始命令
854
+ * @param {string} callerCwd 调用方 cwd(相对 cd 目标的解析基准之一)
855
+ * @param {string} projectRoot 主仓根(worktree 形态下的正确锚点基准)
856
+ * @param {boolean} inWorktree 调用方是否在 worktree 内(决定 ../ 的解析基准)
857
+ * @returns {{hits: Array<{key:string,repoRoot:string,resolved:string,target:string}>, segments: string[]}|null}
858
+ * 无 cd 段 / 无 repos 注册 / 无命中 → null
859
+ */
860
+ function analyzeCrossRepoCd(command, callerCwd, projectRoot, inWorktree) {
861
+ const segments = splitCommandParts(command)
862
+ const cdTargets = []
863
+ for (const seg of segments) {
864
+ const m = seg.match(/^cd\s+([^\s&|;]+)/i)
865
+ if (m) cdTargets.push(m[1])
866
+ }
867
+ if (cdTargets.length === 0) return null
868
+ const repos = loadLocalConfig(projectRoot).repos
869
+ if (!repos || typeof repos !== 'object') return null
870
+ const hits = []
871
+ for (const t of cdTargets) {
872
+ // 双基准:shell 实际解析(callerCwd——worktree 内 ../ 即存储目录错位形态)与意图解析
873
+ // (worktree 形态按主仓根、主仓 cwd 即 shell 本身)。注册命中按意图基准;纠偏提示按
874
+ // 「shell 解析落在 worktree 存储目录」判定。
875
+ const shellResolved = path.isAbsolute(t) ? t : path.resolve(callerCwd, t)
876
+ const intended = path.isAbsolute(t) ? t : path.resolve(inWorktree ? projectRoot : callerCwd, t)
877
+ for (const [key, raw] of Object.entries(repos)) {
878
+ if (typeof raw !== 'string' || !raw) continue
879
+ const repoRoot = path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw)
880
+ if (intended === repoRoot || isPathInside(intended, repoRoot)) {
881
+ hits.push({ key, repoRoot, resolved: intended, shellResolved, target: t })
882
+ break
883
+ }
884
+ }
885
+ }
886
+ return hits.length > 0 ? { hits, segments } : null
887
+ }
888
+
837
889
  /**
838
890
  * 判断 Bash 命令是否应被拦截
839
891
  * @param {string} command - Bash 命令字符串
@@ -846,8 +898,19 @@ function shouldBlockBash(command, cwd) {
846
898
  const callerCwd = cwd || process.cwd()
847
899
  const projectRoot = findProjectRoot(callerCwd)
848
900
 
849
- // cwd 在 worktree 内 → 全部放行
850
- if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
901
+ // cwd 在 worktree 内 → 全部放行(跨仓锚点纠偏留痕:../注册仓 在 worktree 内解析到
902
+ // worktrees 存储目录——不拦只纠正,known-issues③)
903
+ if (isInsideRegisteredWorktree(callerCwd, projectRoot)) {
904
+ try {
905
+ const xr = analyzeCrossRepoCd(command, callerCwd, projectRoot, true)
906
+ for (const h of (xr && xr.hits) || []) {
907
+ if (isInsideWorktreeStorage(h.shellResolved, projectRoot)) {
908
+ console.error(`[sillyspec hook] ℹ️ 跨仓锚点纠偏:cd ${h.target} 在 worktree 内实际会解析到 ${h.shellResolved}(worktrees 存储目录,非真实兄弟仓)。注册仓 ${h.key} 的真实路径:${h.repoRoot}——跨仓测试/构建请改用真实路径。`)
909
+ }
910
+ }
911
+ } catch { /* 纠偏 fail-soft:解析异常不影响放行 */ }
912
+ return { blocked: false }
913
+ }
851
914
 
852
915
  // 阶段门禁(直读 sillyspec.db)
853
916
  const stage = readCurrentStage(projectRoot) || '(none)'
@@ -857,6 +920,16 @@ function shouldBlockBash(command, cwd) {
857
920
  const localConfig = loadLocalConfig(projectRoot)
858
921
  const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
859
922
  if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
923
+ // 跨仓注册仓测试类放行(known-issues③):verify 等阶段从主仓跑兄弟仓测试/lint 是合法
924
+ // 只读操作。从严三条件:cd 目标命中 repos 注册根 + 其余片段全部测试/lint 类 + 危险
925
+ // 黑名单不沾——写类/混合命令维持 stage 门禁原判(fail-closed)。
926
+ if (!matchDangerBlacklist(command)) {
927
+ const xr = analyzeCrossRepoCd(command, callerCwd, projectRoot, false)
928
+ if (xr && xr.segments.every(seg => /^cd\s+/i.test(seg) || CROSS_REPO_TEST_RE.test(seg))) {
929
+ console.error(`[sillyspec hook] 放行依据 = 跨仓注册仓(${xr.hits.map(h => h.key).join('、')})测试/lint 类命令(known-issues③ 锚点感知,其余片段全测试类+黑名单不沾)`)
930
+ return { blocked: false }
931
+ }
932
+ }
860
933
  return {
861
934
  blocked: true,
862
935
  reason: buildStageHint(stage)