sillyspec 3.28.9 → 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 (55) 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-list.js +73 -20
  7. package/src/change-risk-profile.js +51 -3
  8. package/src/config-schema.js +51 -0
  9. package/src/contract-matrix.js +84 -5
  10. package/src/cross-repo-reconcile.js +134 -0
  11. package/src/design-facts.js +94 -18
  12. package/src/docs-check.js +14 -10
  13. package/src/docs-debt.js +7 -2
  14. package/src/docs-gate.js +42 -15
  15. package/src/endpoint-extractor.js +50 -2
  16. package/src/foreign-declared.js +25 -16
  17. package/src/hooks/worktree-guard.js +75 -2
  18. package/src/index.js +147 -2
  19. package/src/knowledge-classify.js +20 -1
  20. package/src/machine-interface.js +26 -0
  21. package/src/probe7-anchor-check.js +87 -0
  22. package/src/progress/stage-machine.js +34 -5
  23. package/src/progress.js +1 -1
  24. package/src/quicklog.js +240 -5
  25. package/src/run/command.js +25 -2
  26. package/src/run/complete-handlers.js +172 -1
  27. package/src/run/complete.js +143 -11
  28. package/src/run/gate-snapshot.js +372 -2
  29. package/src/run/gates.js +130 -12
  30. package/src/run/prompt.js +166 -7
  31. package/src/run/shared.js +81 -1
  32. package/src/scope-audit.js +101 -31
  33. package/src/stage-contract-spec.js +14 -0
  34. package/src/stage-contract.js +314 -8
  35. package/src/stage-review-checklist.js +3 -1
  36. package/src/stage-review.js +137 -2
  37. package/src/stages/archive.js +9 -5
  38. package/src/stages/brainstorm.js +7 -1
  39. package/src/stages/cmd-existence.js +9 -3
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/knowledge.js +24 -3
  42. package/src/stages/plan-postcheck.js +74 -10
  43. package/src/stages/plan.js +8 -1
  44. package/src/stages/quick.js +5 -5
  45. package/src/stages/verify.js +3 -2
  46. package/src/sync-noise.js +57 -1
  47. package/src/sync.js +77 -4
  48. package/src/task-review.js +84 -29
  49. package/src/verify-facts-schema.js +78 -9
  50. package/src/verify-postcheck.js +439 -68
  51. package/src/verify-probes.js +1156 -45
  52. package/src/worktree-apply.js +323 -8
  53. package/src/worktree-deps.js +24 -1
  54. package/src/worktree.js +274 -10
  55. package/src/write-audit.js +55 -0
@@ -25,6 +25,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
25
25
  import { join } from 'node:path'
26
26
  import { parseDecisions } from './decision-distill.js'
27
27
  import { parseFileChangeListDetailed } from './change-list.js'
28
+ import { parseDesignCoverageByRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
28
29
  import { parseModuleMapSimple } from './modules.js'
29
30
 
30
31
  // ---------------------------------------------------------------------------
@@ -331,7 +332,8 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
331
332
  lines.push('')
332
333
  lines.push('| 操作 | 文件路径 | 说明 |')
333
334
  lines.push('|---|---|---|')
334
- 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 用户反馈⑥-①,写作期即提示) -->')
335
337
  lines.push('')
336
338
 
337
339
  lines.push('## 接口定义')
@@ -423,25 +425,99 @@ export function validateDesignFileList({ changeDir, cwd } = {}) {
423
425
  if (!existsSync(designPath)) {
424
426
  return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 不存在'] }
425
427
  }
426
- const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
427
- if (!Array.isArray(entries) || entries.length === 0) {
428
- return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
428
+
429
+ // 单段存在性核验内核:NEW:/glob/占位符口径与原实现一致,仅把「根」参数化
430
+ const checkAgainstRoot = (root, rawPaths, repoLabel) => {
431
+ for (const raw of rawPaths) {
432
+ const rawPath = String(raw || '').trim()
433
+ if (!rawPath) continue
434
+ if (rawPath.startsWith('NEW:')) continue // 计划新建豁免(与 validateTargetFiles 同语义)
435
+ if (rawPath.includes('*') || rawPath.includes('?')) {
436
+ warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${rawPath}`)
437
+ continue
438
+ }
439
+ const normalized = stripPathPlaceholders(rawPath)
440
+ if (!normalized || normalized === '.') continue
441
+ if (!existsSync(join(root, normalized))) {
442
+ errors.push({
443
+ path: rawPath,
444
+ message: `design_file_ref_invalid:文件变更清单条目「${rawPath}」在${repoLabel}(${root})下不存在且无 NEW: 前缀(幻觉路径/书写错误)——修正路径,或计划新建的文件改为 NEW:${rawPath} 前缀`,
445
+ })
446
+ }
447
+ }
429
448
  }
430
- for (const e of entries) {
431
- const raw = String((e && e.path) || '').trim()
432
- if (!raw) continue
433
- if (raw.startsWith('NEW:')) continue // 计划新建豁免(与 validateTargetFiles 同语义)
434
- if (raw.includes('*') || raw.includes('?')) {
435
- warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${raw}`)
436
- continue
449
+
450
+ // 坑 design-file-ref-cross-repo-blind(2026-09-15 wp 会话实证,41 条误报):存在性核验
451
+ // 此前恒以主仓 cwd 为根——design §6 按仓分段(D-014)下的跨仓「修改」条目(如
452
+ // sub-grid-security 的 src/common/router.js)在其自己仓里明明存在,却被逼标 NEW: 前缀
453
+ // 过 gate(标「修改」的既有文件被迫声明为计划新建,语义撒谎)。对齐
454
+ // validateDesignFileCoverage 的分段口径:复用 parseDesignCoverageByRepo 按 `## <repo>
455
+ // 仓变更` 段头切分,main 段核主仓根,跨仓段核 local.yaml repos: 注册根;无段头时
456
+ // 整章归 main(原行为零回归)。
457
+ const { byRepo, hasSegmentHeader, malformedHeaders } = parseDesignCoverageByRepo(designPath)
458
+ for (const h of malformedHeaders) {
459
+ warnings.push(`design 清单疑似仓变更段头格式异常(该段文件可能被记到上一段仓,覆盖对账会点名):${h}`)
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
+
471
+ if (!hasSegmentHeader) {
472
+ const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
473
+ if (!Array.isArray(entries) || entries.length === 0) {
474
+ return { ok: true, errors, warnings: [...warnings, 'design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
437
475
  }
438
- const normalized = stripPathPlaceholders(raw)
439
- if (!normalized || normalized === '.') continue
440
- if (!existsSync(join(cwd, normalized))) {
441
- errors.push({
442
- path: raw,
443
- message: `design_file_ref_invalid:文件变更清单条目「${raw}」既不存在也无 NEW: 前缀(幻觉路径/书写错误)——修正路径,或计划新建的文件改为 NEW:${raw} 前缀`,
444
- })
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, '主仓')
510
+ return { ok: errors.length === 0, errors, warnings }
511
+ }
512
+
513
+ // 有段头:逐段按其仓根核验。
514
+ for (const [repo, paths] of byRepo) {
515
+ if (repo === 'main') {
516
+ checkAgainstRoot(cwd, paths, '主仓')
517
+ } else if (registry && registry.has(repo)) {
518
+ checkAgainstRoot(registry.get(repo), paths, `${repo} 仓`)
519
+ } else {
520
+ warnings.push(`design 清单「${repo} 仓变更」段的 repo 未在 local.yaml repos: 注册,跳过该段存在性核验(execute 启动会 fail-closed 拦截,先跑 sillyspec local register-repo ${repo} <仓根路径>)`)
445
521
  }
446
522
  }
447
523
  return { ok: errors.length === 0, errors, warnings }
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)