sillyspec 3.28.2 → 3.28.4

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 (67) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +16 -55
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-continue/SKILL.md +8 -38
  4. package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +19 -249
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-propose/SKILL.md +6 -77
  8. package/.claude/skills/sillyspec-resume/SKILL.md +8 -57
  9. package/.claude/skills/sillyspec-state/SKILL.md +7 -46
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +14 -156
  11. package/AGENTS.md +24 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/src/archive-delta.js +127 -2
  15. package/src/change-delete.js +2 -1
  16. package/src/change-list.js +7 -2
  17. package/src/change-risk-profile.js +77 -6
  18. package/src/config-cat.js +12 -1
  19. package/src/constants.js +9 -1
  20. package/src/design-facts.js +64 -0
  21. package/src/dispatch/backends/sillyhub-mcp.js +17 -5
  22. package/src/docs-check.js +153 -27
  23. package/src/docs-migrate.js +124 -0
  24. package/src/doctor-diagnostics.js +441 -5
  25. package/src/foreign-declared.js +53 -2
  26. package/src/index.js +353 -28
  27. package/src/module-impact.js +77 -12
  28. package/src/plan-adopt-waves.js +13 -5
  29. package/src/progress/change-registry.js +35 -0
  30. package/src/progress.js +64 -1
  31. package/src/quick-recommend.js +6 -1
  32. package/src/quicklog.js +10 -2
  33. package/src/run/command.js +159 -13
  34. package/src/run/complete-handlers.js +162 -9
  35. package/src/run/complete.js +104 -5
  36. package/src/run/gates.js +116 -8
  37. package/src/run/progress-confirm.js +66 -0
  38. package/src/run/prompt.js +235 -36
  39. package/src/run/quick-audit.js +12 -5
  40. package/src/run/scan-profile.js +20 -2
  41. package/src/run/shared.js +84 -11
  42. package/src/run/stage.js +42 -0
  43. package/src/runtime-hygiene.js +84 -0
  44. package/src/scan-postcheck.js +5 -1
  45. package/src/spec-sync.js +63 -8
  46. package/src/stage-contract.js +83 -3
  47. package/src/stage-review.js +3 -0
  48. package/src/stages/archive.js +4 -4
  49. package/src/stages/brainstorm-auto.js +2 -1
  50. package/src/stages/brainstorm.js +11 -25
  51. package/src/stages/doctor.js +41 -515
  52. package/src/stages/execute.js +10 -14
  53. package/src/stages/plan-postcheck.js +191 -7
  54. package/src/stages/plan.js +7 -27
  55. package/src/stages/quick.js +15 -13
  56. package/src/stages/verify.js +23 -21
  57. package/src/sync-noise.js +158 -0
  58. package/src/sync.js +42 -17
  59. package/src/task-review.js +225 -1
  60. package/src/verify-facts-schema.js +141 -0
  61. package/src/verify-postcheck.js +421 -49
  62. package/src/verify-probes.js +142 -13
  63. package/src/workflow.js +6 -0
  64. package/src/worktree-apply.js +51 -13
  65. package/src/worktree.js +5 -1
  66. package/templates/agents-instruction.md +1 -1
  67. package/.claude/skills/verify-per-user/SKILL.md +0 -6
@@ -352,3 +352,67 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
352
352
 
353
353
  return lines.join('\n')
354
354
  }
355
+
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // design.md 文件清单行级核验(change: 2026-09-07-ir-hardening,D-004@v1,FR-02)
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /** 剥路径中的 <...> 占位段(`docs/<project>/scan` → `docs/`;占位形态对齐 collectDocRefs 层1 容差先例) */
362
+ function stripPathPlaceholders(p) {
363
+ return String(p || '').replace(/<[^/>]*>/g, '').replace(/\/{2,}/g, '/').replace(/\/$/, '')
364
+ }
365
+
366
+ /**
367
+ * validateDesignFileList —— design.md 文件变更清单逐条存在性核验(幻觉路径 gate)。
368
+ *
369
+ * 分级(design D-004 + Grill P2-⑤):
370
+ * - `NEW:` 前缀(解析器不剥前缀,startsWith 直判)→ 通过(计划新建文件豁免)
371
+ * - 路径存在(cwd 相对 existsSync;判前剥 <...> 占位段)→ 通过
372
+ * - 含 glob 字符(* / ?)→ warning 跳过(pathMatches 体系本支持 glob 合法形态,existsSync 恒 false 会假 ERROR——fail-soft)
373
+ * - 其余 → errors { path, message }(信封 code design_file_ref_invalid)
374
+ * - design.md 不存在/无清单段/解析异常 → warnings(small 变更可无清单;fail-soft 不阻断)
375
+ *
376
+ * 范围:keepSillyspecDocs=true——`.sillyspec/docs/**` 交付物路径在核验范围内(其余 .sillyspec/ 条目解析器本就丢弃)。
377
+ * 纯函数不落盘。
378
+ *
379
+ * @param {{ changeDir: string, cwd: string }} opts
380
+ * @returns {{ ok: boolean, errors: Array<{path, message}>, warnings: string[] }}
381
+ */
382
+ export function validateDesignFileList({ changeDir, cwd } = {}) {
383
+ const errors = []
384
+ const warnings = []
385
+ try {
386
+ if (!changeDir || !cwd) {
387
+ return { ok: true, errors, warnings: ['design 清单核验跳过:缺少 changeDir/cwd'] }
388
+ }
389
+ const designPath = join(changeDir, 'design.md')
390
+ if (!existsSync(designPath)) {
391
+ return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 不存在'] }
392
+ }
393
+ const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
394
+ if (!Array.isArray(entries) || entries.length === 0) {
395
+ return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
396
+ }
397
+ for (const e of entries) {
398
+ const raw = String((e && e.path) || '').trim()
399
+ if (!raw) continue
400
+ if (raw.startsWith('NEW:')) continue // 计划新建豁免(与 validateTargetFiles 同语义)
401
+ if (raw.includes('*') || raw.includes('?')) {
402
+ warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${raw}`)
403
+ continue
404
+ }
405
+ const normalized = stripPathPlaceholders(raw)
406
+ if (!normalized || normalized === '.') continue
407
+ if (!existsSync(join(cwd, normalized))) {
408
+ errors.push({
409
+ path: raw,
410
+ message: `design_file_ref_invalid:文件变更清单条目「${raw}」既不存在也无 NEW: 前缀(幻觉路径/书写错误)——修正路径,或计划新建的文件改为 NEW:${raw} 前缀`,
411
+ })
412
+ }
413
+ }
414
+ return { ok: errors.length === 0, errors, warnings }
415
+ } catch (e) {
416
+ return { ok: true, errors, warnings: [`design 清单核验异常 fail-soft 放行:${e && e.message ? e.message : e}`] }
417
+ }
418
+ }
@@ -234,11 +234,23 @@ dispatch 后在当前 step 内主动轮询,不阻塞等待:
234
234
 
235
235
  1. **轮询 \`list_workers\`**:间隔默认 15s,可配 local.yaml \`dispatch.poll_interval_ms\`
236
236
  - 入参 \`mission_id\`;关注每个 worker 的 status(pending → running → completed/failed)
237
- 2. **per-worker 超时**:默认可配 local.yaml \`dispatch.worker_timeout_ms\`;超时 → 三步兜底:
238
- - a. **标记**:调 \`report_progress\` tool(worker_id + 超时说明)记录该 worker 超时
239
- - b. **kill lease 防双写**:调 \`report_progress\` tool(worker_id + \`kill: true\` + marker)终止 lease
240
- (等价 client.js \`killLease(workerId)\` 路径;SillyHub 当前无专用 kill tool,路径A 落地后升级为专用 kill)
241
- - c. **fallback Local 重派**:该 task 改用本机 Agent tool 重派(调 \`sillyspec dispatch hint\` 走 Local 模板)
237
+ 2. **worker 活性知情决策(2026-09-07-agent-liveness-states task-13)**:\`list_workers\`
238
+ 返回的 worker 在 **running 期间** 附 \`liveness\` 字段(\`{ state, evidence, derived_at }\`,
239
+ 无则为 null)——\`state\` 取值 \`working\` / \`blocked\` / \`idle\` / \`ended\` / \`unknown\`
240
+ (daemon liveness 推导;\`blocked\` 主源为第一方权限事件,PERMISSION_REQUEST 等人)。
241
+ 轮询时按活性分流,替代无脑超时 kill:
242
+ - a. **\`liveness.state=blocked\` 且持续超阈值**(对齐平台 \`BLOCKED_ALERT_MS\`=120s 口径,
243
+ 连续两轮轮询仍 blocked 即视为持续)→ **升级给人**:告知用户该 worker 在等权限确认
244
+ (harness + evidence),由人处理(平台会同步发 agent_blocked 通知);**绝不 kill、
245
+ 绝不自动批准**(自动批准权限提示=代人做安全决策,安全敏感)。
246
+ - b. **\`liveness.state=working\` 且久无终态** → 属长任务场景(日志持续增长、跑测试等),
247
+ 按既有 \`dispatch.worker_timeout_ms\` 继续等待,**不抢跑 kill**——区分「在干活的长任务」
248
+ 与「死了」正是活性字段的价值。
249
+ - c. **仅当超时且无 liveness / \`liveness.state=unknown\`** → 走既有三步兜底(超时兜底路径):
250
+ - ① **标记**:调 \`report_progress\` tool(worker_id + 超时说明)记录该 worker 超时
251
+ - ② **kill lease 防双写**:调 \`report_progress\` tool(worker_id + \`kill: true\` + marker)终止 lease
252
+ (等价 client.js \`killLease(workerId)\` 路径;SillyHub 当前无专用 kill tool,路径A 落地后升级为专用 kill)
253
+ - ③ **fallback Local 重派**:该 task 改用本机 Agent tool 重派(调 \`sillyspec dispatch hint\` 走 Local 模板)
242
254
  3. **worker 终态(completed / failed / killed)** → 进入下方回收约定
243
255
 
244
256
  ${recycleRule}`
package/src/docs-check.js CHANGED
@@ -28,11 +28,16 @@ import { parseModuleMapSimple } from './modules.js'
28
28
  * 不同设备兄弟仓库位置不同,未配映射时默认跳过本地校验(防跨设备误报);
29
29
  * 在本机 .sillyspec/local.yaml 的 docs-check.cross_repo_roots 配
30
30
  * `<仓库名>: <本机绝对路径>` 后,走与本地引用完全相同的层1(行号边界)+ 层2(关键词窗口)校验。
31
- * 正则组:1=仓库名(可选),2=文件,3=start,4=end。 */
32
- const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]+\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go)):(\d+)(?:-(\d+))?/g
31
+ * 正则组:1=仓库名(可选),2=文件,3=start,4=end。
32
+ * 文件段展开循环形(D-006,2026-09-08-docs-fix-capability):普通段可选 + 零或多个「闭合括号段+普通段」
33
+ * 迭代——支持 Next.js 路由组 (dashboard) 等括号路径;markdown 链接 [t](foo.js:12) 在 `(` 处括号段
34
+ * 要求 `)` 先于 `:` 闭合而失败,回落 foo.js:12 零回归;嵌套 ((x)) 部分提取(与旧行为一致)。
35
+ * ⚠ 不用原子序列 (?:A+|B+)+——经典嵌套量词 ReDoS(Grill 实证 n=30 长 token 73.8s),
36
+ * 展开循环每次迭代必含括号段 → 划分唯一 → 线性。 */
37
+ 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
33
38
 
34
39
  /** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八) */
35
- const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
40
+ export const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
36
41
 
37
42
  /**
38
43
  * 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
@@ -99,6 +104,44 @@ export function collectDocRefs(md) {
99
104
  return refs
100
105
  }
101
106
 
107
+ /**
108
+ * 豁免判定双通道(FR-3 / D-003,2026-09-08-docs-fix-capability):
109
+ * ①路径段:POSIX 归一后任一路径段 === archive 或 finished(历史快照/归档文档天然过时);
110
+ * ②frontmatter:文件头 --- 块内行 `doc_type: snapshot`(机械精确匹配——容忍行内注释与首尾
111
+ * 空白,带引号 doc_type: "snapshot" 不识别,不做 YAML 解析)。
112
+ * 拆两函数:路径段判免 IO(readFileSync 前);frontmatter 判需文件内容。
113
+ * @param {string} relPath 文档相对路径(POSIX 或 Windows 反斜杠均可)
114
+ * @returns {boolean}
115
+ */
116
+ export function isExemptDocPath(relPath) {
117
+ if (typeof relPath !== 'string' || relPath === '') return false
118
+ const segs = relPath.replace(/\\/g, '/').split('/')
119
+ return segs.some(s => s === 'archive' || s === 'finished')
120
+ }
121
+
122
+ /**
123
+ * frontmatter 通道:文件头 --- 块内机械扫描 `doc_type: snapshot` 行。
124
+ * @param {string} mdText 文档全文(已读取)
125
+ * @returns {boolean}
126
+ */
127
+ export function isExemptDocFrontmatter(mdText) {
128
+ if (typeof mdText !== 'string' || !mdText.startsWith('---')) return false
129
+ const end = mdText.indexOf('\n---', 3)
130
+ if (end === -1) return false
131
+ const block = mdText.slice(3, end)
132
+ return /^[ \t]*doc_type:[ \t]*snapshot[ \t]*(?:#.*)?$/m.test(block)
133
+ }
134
+
135
+ /**
136
+ * 双通道合并(调用方便利包装)。
137
+ * @param {string} relPath 文档相对路径
138
+ * @param {string} [mdText] 文档全文(路径段命中时可省——调用方先判路径段免 IO)
139
+ * @returns {boolean}
140
+ */
141
+ export function isExemptDoc(relPath, mdText) {
142
+ return isExemptDocPath(relPath) || (mdText !== undefined && isExemptDocFrontmatter(mdText))
143
+ }
144
+
102
145
  /**
103
146
  * 纯函数:判定 token 是否「像代码符号」:首字符字母/_/$,且含大写字母/下划线/点/$ 之一。
104
147
  * 纯小写英文单词(local/platform/abort)→ false(自然语言,跳过断言防误报)。
@@ -391,7 +434,7 @@ export function runDocsCheck(opts) {
391
434
  // 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
392
435
  const {
393
436
  projectRoot, docs = null, paths: rawPaths = DEFAULT_DOC_PATHS, skip = [],
394
- keywordAssert = true, crossRepoRoots = {},
437
+ keywordAssert = true, crossRepoRoots = {}, exempt = true,
395
438
  } = opts || {}
396
439
  const paths = Array.isArray(rawPaths) && rawPaths.length > 0 ? rawPaths : DEFAULT_DOC_PATHS
397
440
  const warnings = []
@@ -405,6 +448,10 @@ export function runDocsCheck(opts) {
405
448
  let kwChecked = 0
406
449
  // repo:// 跨仓引用:未配映射被跳过的数量(不计入 total/invalid,跨设备零误报)
407
450
  let crossRepoSkipped = 0
451
+ // 豁免文档数(archive/finished 路径段或 frontmatter doc_type: snapshot,不计 invalid——FR-3)
452
+ let skippedExempt = 0
453
+ // 模糊路径引用数(含 `...` 省略号,跳过校验——FR-1.2)
454
+ let skippedFuzzy = 0
408
455
  // per-call 缓存:裸名引用的 src/ 全树扫描结果 + 候选文件行数组——100 文档 × 10 裸名引用
409
456
  // 此前 = 1000 次全树 walk + 同文件 N 次重读(2026-08-21 性能审查 PERF-5)。仅本次调用
410
457
  // 生命周期内有效,不跨调用(防测试流程中途改树后读到陈旧结果)
@@ -420,11 +467,17 @@ export function runDocsCheck(opts) {
420
467
  })
421
468
  continue
422
469
  }
470
+ // 豁免双通道(FR-3 / D-003):路径段判定免 IO(readFileSync 前);
471
+ // frontmatter 判定需读文件(读取后)。豁免文档整体跳过不计 invalid。
472
+ if (exempt && isExemptDocPath(docRel)) { skippedExempt++; continue }
423
473
  const md = readFileSync(docAbs, 'utf8')
474
+ if (exempt && isExemptDocFrontmatter(md)) { skippedExempt++; continue }
424
475
  const refs = collectDocRefs(md)
425
476
  const mdLines = md.split(/\r?\n/)
426
477
 
427
478
  for (const r of refs) {
479
+ // 模糊路径(含 `...` 省略号):跳过校验(FR-1.2,宁漏不误报——文档作者明示意图非精确路径)
480
+ if (r.file.includes('...')) { skippedFuzzy++; continue }
428
481
  // repo:// 跨仓引用:未配映射 → 跳过(不同设备仓库位置不同,不按本仓解析误报);
429
482
  // 配了映射 → 以映射根为唯一候选,走与本地引用相同的层1+层2 校验。
430
483
  let candidates
@@ -452,10 +505,20 @@ export function runDocsCheck(opts) {
452
505
  }
453
506
  total++
454
507
  if (candidates.length === 0) {
508
+ // FR-4(candidates JSON):带 / 路径的文件不存在引用,basename 树扫非空 → 附同名文件候选
509
+ // (机械数据供脚本消费;裸文件名引用 resolveCandidates 已全树扫返回空时重扫恒空,无意义——Grill 修正)
510
+ const fixEntry = { fixable: false, reason: '文件不存在,无法自动定位' }
511
+ if (r.file.includes('/')) {
512
+ const baseName = r.file.slice(r.file.lastIndexOf('/') + 1)
513
+ const sameName = findInTree(join(projectRoot, 'src'), baseName, '', treeCache)
514
+ if (sameName.length > 0) {
515
+ fixEntry.candidates = sameName.map(rel => ({ file: 'src/' + rel }))
516
+ }
517
+ }
455
518
  invalid.push({
456
519
  doc: docRel, docLine: r.docLine, ref: r.ref,
457
520
  reason: '文件不存在(含 / 按仓库根解析;裸文件名在 src/ 递归)',
458
- fix: { fixable: false, reason: '文件不存在,无法自动定位' },
521
+ fix: fixEntry,
459
522
  })
460
523
  continue
461
524
  }
@@ -489,13 +552,13 @@ export function runDocsCheck(opts) {
489
552
  // 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
490
553
  suggest: suggestLines(candidates, tokens, linesCache),
491
554
  // 修复分类(--fix 判定依据):全量候选 token 命中打分——唯一命中或选优严格领先可自动重锚
492
- fix: classifyFix(candidates, tokens, r.start, linesCache),
555
+ fix: classifyFix(candidates, tokens, r.start, linesCache, projectRoot),
493
556
  })
494
557
  }
495
558
  }
496
559
  }
497
560
 
498
- return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped }
561
+ return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped, skippedExempt, skippedFuzzy }
499
562
  }
500
563
 
501
564
  /**
@@ -532,13 +595,14 @@ function suggestLines(candidates, tokens, linesCache = null) {
532
595
  * @param {string[]} candidates resolveCandidates 返回的全量候选绝对路径
533
596
  * @param {string[]} tokens 层2 提取的期望 token(keywordAssert=false 时为空数组)
534
597
  * @param {number|null} [refStart] 文档引用的旧行号(打分用距离信号;范围引用取 start)
535
- * @returns {{ fixable: boolean, newLine?: number, reason: string }}
598
+ * @param {string} [projectRootHint] candidates 相对路径显示锚(tie candidates 的 file 字段用)
599
+ * @returns {{ fixable: boolean, newLine?: number, candidates?: Array<{file?: string, line: number}>, reason: string }}
536
600
  */
537
601
  function escapeReLocal(s) {
538
602
  return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
539
603
  }
540
604
 
541
- function classifyFix(candidates, tokens, refStart = null, linesCache = null) {
605
+ function classifyFix(candidates, tokens, refStart = null, linesCache = null, projectRootHint = '') {
542
606
  if (tokens.length === 0) {
543
607
  return { fixable: false, reason: '无 token 符号线索(纯位置引用或关键词断言关闭),无法自动定位' }
544
608
  }
@@ -578,7 +642,16 @@ function classifyFix(candidates, tokens, refStart = null, linesCache = null) {
578
642
  }
579
643
  }
580
644
  const shown = ranked.slice(0, 8).map(([l]) => l).join('、') + (ranked.length > 8 ? '…' : '')
581
- return { fixable: false, reason: `token 多处命中且最优两行同分(候选行号:${shown}),歧义需人工确认(D-006 保守默认)` }
645
+ // FR-4(candidates JSON):tie 歧义时附机械候选行号数组(供 --json 消费,无置信度——D-001 边界)。
646
+ // file 字段仅在单候选文件时有意义(多候选同分行号已合并,行号不区分文件归属)。
647
+ const tieCandidates = ranked.slice(0, 8).map(([l]) =>
648
+ candidates.length === 1 ? { file: relDisplay(candidates[0], projectRootHint), line: l } : { line: l }
649
+ )
650
+ return {
651
+ fixable: false,
652
+ candidates: tieCandidates,
653
+ reason: `token 多处命中且最优两行同分(候选行号:${shown}),歧义需人工确认(D-006 保守默认)`,
654
+ }
582
655
  }
583
656
 
584
657
  /**
@@ -701,23 +774,35 @@ function extractKnownFailureKeys(yamlText) {
701
774
  if (inline) {
702
775
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
703
776
  }
704
- // 坑 verify-known-failures-comment-line-truncation 同款(与 verify-postcheck.extractKnownFailures
705
- // 口径互指对齐):块内注释行/空行打断连续列表项捕获链 → 注释后的键静默丢失。块定义放行
706
- // 注释行与空行;提取侧只认 - 项行;行尾注释须 # 前有空白(裸 # 不截);引号值原样保留。
707
- const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
708
- if (block) {
709
- return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
710
- .map(s => {
711
- const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
712
- const full = item.match(/^(['"])(.*)\1$/)
713
- if (full) return full[2]
714
- const stripped = item.replace(/[ \t]+#.*$/, '').trim()
715
- const q2 = stripped.match(/^(['"])(.*)\1$/)
716
- return q2 ? q2[2] : stripped
717
- })
718
- .filter(Boolean)
777
+ // 坑 verify-known-failures-comment-line-truncation → verify-known-failures-block-fragile-chain
778
+ // (与 verify-postcheck.extractKnownFailures 口径互指对齐,逐字同步):块式捕获曾要求
779
+ // 「- 项行 / 注释行 / 空行」连续成链,手改残留的缩进杂行/空项断链 → 其后键静默丢失。
780
+ // 改为逐行扫描:块 = 头之后连续的「缩进行 / 注释行(任意缩进含列首)/ 空行」,列首正文行
781
+ // 收块;块内杂行容忍不提取但不断链,`- ` 项行无论隔着什么都提取。
782
+ // 行尾注释须 # 前有空白(裸 # 不截);引号值原样保留。
783
+ const lines = yaml.split('\n')
784
+ const headIdx = lines.findIndex(l => /^known_failures:\s*(?:#.*)?$/.test(l))
785
+ if (headIdx === -1) return []
786
+ const itemRe = /^[ \t]+-[ \t]+(.+)$/
787
+ const items = []
788
+ for (let i = headIdx + 1; i < lines.length; i++) {
789
+ const l = lines[i]
790
+ if (l.trim() === '') continue // 空行:块内分隔,放行
791
+ if (/^[ \t]*#/.test(l)) continue // 注释行(任意缩进含列首):段头/归因注记,放行
792
+ if (!/^[ \t]/.test(l)) break // 列首正文行:下一个顶层键,收块
793
+ const m = l.match(itemRe) // 其余缩进行一律留在块内;项行才提取(杂行不提取但不断链)
794
+ if (m) items.push(m[1])
719
795
  }
720
- return []
796
+ return items
797
+ .map(s => {
798
+ const item = s.trim()
799
+ const full = item.match(/^(['"])(.*)\1$/)
800
+ if (full) return full[2]
801
+ const stripped = item.replace(/[ \t]+#.*$/, '').trim()
802
+ const q2 = stripped.match(/^(['"])(.*)\1$/)
803
+ return q2 ? q2[2] : stripped
804
+ })
805
+ .filter(Boolean)
721
806
  }
722
807
 
723
808
  /**
@@ -733,8 +818,15 @@ export function readDecisionRulesConfig(projectRoot) {
733
818
  const p = join(projectRoot, '.sillyspec', 'local.yaml')
734
819
  if (!existsSync(p)) return fallback
735
820
  const raw = readFileSync(p, 'utf8')
821
+ // knownFailures 用容错行扫描提取(与 verify-postcheck.extractKnownFailures 同款),且先于
822
+ // jsYaml——YAML 整体不合法(如 known_failures 块内手改残迹行)时 jsYaml 抛错不能连带丢掉
823
+ // 豁免键(坑 verify-known-failures-block-fragile-chain:曾整段 catch 回 fallback、键静默丢)。
824
+ // jsYaml 只供 behind_threshold,单独兜底;豁免键在任何形态下存活。
736
825
  const knownFailures = extractKnownFailureKeys(raw)
737
- const doc = jsYaml.load(raw)
826
+ let doc = null
827
+ try {
828
+ doc = jsYaml.load(raw)
829
+ } catch { doc = null }
738
830
  if (!doc || typeof doc !== 'object') return { ...fallback, knownFailures }
739
831
  const d = doc['decisions']
740
832
  if (!d || typeof d !== 'object' || Array.isArray(d)) return { ...fallback, knownFailures }
@@ -1030,3 +1122,37 @@ export function runChangeNameAdvisory(opts) {
1030
1122
  findings.sort((a, b) => a.doc.localeCompare(b.doc) || a.docLine - b.docLine || a.name.localeCompare(b.name))
1031
1123
  return { empty: scanned === 0, scannedDocs: scanned, mentions, uniqueNames: seenNames.size, findings, exempted }
1032
1124
  }
1125
+
1126
+ /**
1127
+ * 行号漂移自动重锚(quick --done 接线面,2026-09-08 用户反馈②):活文档 file:line 硬编码
1128
+ * 随任何源码插入失效,每回手跑 `docs check --fix` 是机械活。本函数 = --fix 主链路的编程化
1129
+ * 封装:对给定文档集跑一次失效收集 → 取分类器判定确定性的条目(fixable 唯一/优选命中,
1130
+ * 与 index.js --fix 同源构造 fixes)→ applyFixes 写回 → 同口径复跑得回执。
1131
+ *
1132
+ * 边界(与 --fix 的既定红线一致):歧义/零命中条目不动(无 --force 逃生口);仅改写
1133
+ * fixable 条目的行号段,文件名与 token 原样。任何异常上抛由调用方 fail-open。
1134
+ *
1135
+ * @param {string} projectRoot 源码仓根
1136
+ * @param {string[]} docs 本次改动文档的相对路径列表(audit 口径:changedFiles 中的 .md)
1137
+ * @param {{ crossRepoRoots?: string[] }} [cfg] docs-check 配置(readDocsCheckConfig 产物)
1138
+ * @returns {{ applied: number, invalidBefore: number, invalidAfter: number, remaining: number }}
1139
+ * applied=0 时 invalidBefore/After 同值(无可自动修条目,调用方维持纯 advisory)
1140
+ */
1141
+ export function autoReanchorDocRefs(projectRoot, docs, cfg = {}) {
1142
+ // 与 index.js --fix 完全同口径(skip/keywordAssert 透传;undefined 时 runDocsCheck 解构
1143
+ // 默认值兜底:paths 缺省 glob、keywordAssert=true、exempt=true)
1144
+ const scope = { projectRoot, docs, skip: cfg.skip, keywordAssert: cfg.keywordAssert, crossRepoRoots: cfg.crossRepoRoots };
1145
+ const before = runDocsCheck(scope);
1146
+ const fixes = [];
1147
+ for (const inv of before.invalid) {
1148
+ if (inv.fix && inv.fix.fixable === true && Number.isInteger(inv.fix.newLine)) {
1149
+ fixes.push({ doc: inv.doc, docLine: inv.docLine, ref: inv.ref, newRef: inv.ref.replace(/(\d+)(?:-\d+)?$/, String(inv.fix.newLine)) });
1150
+ }
1151
+ }
1152
+ if (fixes.length === 0) {
1153
+ return { applied: 0, invalidBefore: before.invalid.length, invalidAfter: before.invalid.length, remaining: before.invalid.length };
1154
+ }
1155
+ const applied = applyFixes(projectRoot, fixes);
1156
+ const after = runDocsCheck(scope);
1157
+ return { applied: applied.applied, invalidBefore: before.invalid.length, invalidAfter: after.invalid.length, remaining: after.invalid.length };
1158
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * docs migrate:确定性批量路径前缀迁移(2026-09-08-docs-fix-capability / D-002+D-004)。
3
+ *
4
+ * 动机:文档引用批量路径迁移(如 modules/ → backend/app/modules/)是已知改名规则的确定性
5
+ * 替换,不该靠手写临时脚本。薄模块——import 复用 docs-check 的提取/校验/写回,不复制解析正则。
6
+ *
7
+ * 语义(D-002):只做用户显式给定的 --from/--to 前缀替换(零推断);改动面=docs check 所辖
8
+ * .md 文件中的 file:line 引用文本;默认 dry-run 列计划,--apply 才写盘;写盘后自动 docs check
9
+ * 复核(防 from/to 写反)。不碰 git 历史、不改非引用文本。
10
+ *
11
+ * exit code(CLI 层):0=dry-run 零计划或 --apply 后 postCheck 全绿;1=--apply 后仍失效或
12
+ * unverified>0;2=用法/配置错误;dry-run 有计划恒 0(预览不判成败)。
13
+ *
14
+ * 纯 Node 内置模块零依赖(与 docs-check.js 同款)。
15
+ */
16
+ import { join } from 'node:path'
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import {
19
+ collectDocRefs,
20
+ resolveCandidates,
21
+ readDocsCheckConfig,
22
+ applyFixes,
23
+ runDocsCheck,
24
+ DEFAULT_DOC_PATHS,
25
+ walkGlob,
26
+ } from './docs-check.js'
27
+
28
+ /**
29
+ * 生成迁移计划(纯函数,不写盘)。
30
+ * 文档集与 docs check 同源(readDocsCheckConfig paths/skip——校验面即改写面)。
31
+ * @param {{ projectRoot: string, from: string, to: string, docs?: string[], paths?: string[], skip?: string[] }} opts
32
+ * from/to 为用户显式前缀(如 'modules/' → 'backend/app/modules/')
33
+ * @returns {{ plans: Array<{doc, docLine, ref, newRef, verified}>, scanned: number }}
34
+ * plans 按 doc+docLine 排序;verified=false 表示新路径经 resolveCandidates 验证 0 候选(unverified)
35
+ */
36
+ export function planDocsMigrate(opts) {
37
+ const { projectRoot, from, to } = opts || {}
38
+ if (!projectRoot || typeof from !== 'string' || from === '' || typeof to !== 'string' || to === '') {
39
+ throw new Error('planDocsMigrate: from/to 必填且非空')
40
+ }
41
+ const cfg = readDocsCheckConfig(projectRoot)
42
+ const paths = (opts.paths && opts.paths.length > 0 ? opts.paths : null)
43
+ || cfg.paths
44
+ || DEFAULT_DOC_PATHS
45
+ const skip = opts.skip || cfg.skip
46
+ const docFiles = (opts.docs && opts.docs.length > 0)
47
+ ? opts.docs
48
+ : [...new Set(paths.flatMap(p => walkGlob(projectRoot, p, skip)))].sort()
49
+
50
+ const plans = []
51
+ const treeCache = new Map()
52
+ for (const docRel of docFiles) {
53
+ const docAbs = join(projectRoot, docRel)
54
+ if (!existsSync(docAbs)) continue
55
+ const md = readFileSync(docAbs, 'utf8')
56
+ const refs = collectDocRefs(md)
57
+ for (const r of refs) {
58
+ if (r.repo) continue // 跨仓引用不迁移(repo:// 前缀)
59
+ if (!r.file.startsWith(from)) continue
60
+ if (from === to) continue // from=to 时 newRef === ref,无迁移意义(零计划)
61
+ const newFile = to + r.file.slice(from.length)
62
+ const linePart = r.end !== r.start ? `:${r.start}-${r.end}` : `:${r.start}`
63
+ const newRef = newFile + linePart
64
+ const newCandidates = resolveCandidates(projectRoot, newFile, treeCache)
65
+ plans.push({
66
+ doc: docRel,
67
+ docLine: r.docLine,
68
+ ref: r.ref,
69
+ newRef,
70
+ verified: newCandidates.length > 0,
71
+ })
72
+ }
73
+ }
74
+ plans.sort((a, b) => a.doc.localeCompare(b.doc) || a.docLine - b.docLine)
75
+ return { plans, scanned: docFiles.length }
76
+ }
77
+
78
+ /**
79
+ * 执行迁移:默认 dry-run 输出计划表;--apply 写盘(applyFixes)+ 自动 docs check 复核。
80
+ * @param {{ projectRoot: string, from: string, to: string, apply?: boolean, docs?: string[], paths?: string[], skip?: string[] }} opts
81
+ * @returns {{ plans, applied, skipped, unverified, postCheck?: {invalid, total} }}
82
+ */
83
+ export function runDocsMigrate(opts) {
84
+ const { apply = false } = opts || {}
85
+ const { plans, scanned } = planDocsMigrate(opts)
86
+ const unverified = plans.filter(p => !p.verified)
87
+
88
+ if (!apply) {
89
+ // dry-run:打印计划表,零写盘
90
+ if (plans.length === 0) {
91
+ console.log(`✅ docs migrate: 扫描 ${scanned} 个文档,无可迁移引用(from=${opts.from} 无匹配)`)
92
+ } else {
93
+ console.log(`📋 docs migrate 计划(dry-run 未写盘):扫描 ${scanned} 个文档,${plans.length} 处引用可迁移${unverified.length > 0 ? `(${unverified.length} 处 unverified)` : ''}:`)
94
+ for (const p of plans) {
95
+ const mark = p.verified ? '✅' : '⚠️'
96
+ console.log(` ${mark} [${p.doc}:L${p.docLine}] ${p.ref} → ${p.newRef}${p.verified ? '' : '(目标不存在)'}`)
97
+ }
98
+ console.log(`\n确认后加 --apply 写盘;unverified 项请核对 from/to 是否写反。`)
99
+ }
100
+ return { plans, applied: 0, skipped: [], unverified: unverified.length }
101
+ }
102
+
103
+ // --apply:applyFixes 写盘(shape 一致,含 R-04 行内偏移防护)
104
+ const { applied, skipped } = applyFixes(opts.projectRoot, plans)
105
+ // 写盘后自动 docs check 复核(防 from/to 写反)
106
+ const postCheck = runDocsCheck({
107
+ projectRoot: opts.projectRoot,
108
+ paths: opts.paths || null,
109
+ skip: opts.skip || [],
110
+ })
111
+ console.log(`✅ docs migrate: ${applied} 处已写回${skipped.length > 0 ? `、${skipped.length} 处写回跳过` : ''}(${unverified.length} 处 unverified)。`)
112
+ console.log(`📊 迁移后 docs check:${postCheck.invalid.length}/${postCheck.total} 处失效。`)
113
+ if (unverified.length > 0) {
114
+ console.warn(`⚠️ ${unverified.length} 处 unverified(目标不存在),请核对 from/to 是否写反:`)
115
+ for (const p of unverified.slice(0, 10)) console.warn(` - [${p.doc}:L${p.docLine}] ${p.ref} → ${p.newRef}`)
116
+ }
117
+ return {
118
+ plans,
119
+ applied,
120
+ skipped,
121
+ unverified: unverified.length,
122
+ postCheck: { invalid: postCheck.invalid.length, total: postCheck.total },
123
+ }
124
+ }