sillyspec 3.28.6 → 3.28.8

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 (62) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +224 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
  4. package/.husky/pre-push +3 -1
  5. package/.patch16-tmp.mjs +169 -0
  6. package/attachments/2ab2fd87cfa568ca6acadbb934a651d293a0ad8f3227c66a70b56dcf53a0ea3d.pptx +0 -0
  7. package/attachments/dc4c2b314ee0c295e1fe5243073eb5c7df7e429074ad7c14424523fbe86e6193.pptx +0 -0
  8. package/meta.json +11 -0
  9. package/package.json +1 -1
  10. package/src/agent-session-log.js +446 -35
  11. package/src/config-cat.js +6 -3
  12. package/src/config-schema.js +13 -0
  13. package/src/constants.js +3 -0
  14. package/src/db.js +12 -3
  15. package/src/decision-distill.js +6 -2
  16. package/src/dispatch/probe.js +8 -1
  17. package/src/docs-check.js +137 -12
  18. package/src/docs-gate.js +3 -0
  19. package/src/doctor-diagnostics.js +19 -7
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +59 -29
  23. package/src/index.js +168 -64
  24. package/src/knowledge-match.js +90 -6
  25. package/src/local-detect.js +30 -15
  26. package/src/local-register.js +13 -4
  27. package/src/mcp-server.js +38 -8
  28. package/src/progress/change-registry.js +58 -26
  29. package/src/progress/consistency-doctor.js +11 -4
  30. package/src/progress/stage-machine.js +3 -1
  31. package/src/progress/step-store.js +30 -17
  32. package/src/progress.js +26 -0
  33. package/src/quicklog.js +44 -18
  34. package/src/review-dispatch.js +7 -1
  35. package/src/run/command.js +17 -3
  36. package/src/run/complete-handlers.js +40 -10
  37. package/src/run/complete.js +48 -17
  38. package/src/run/concurrent-detect.js +1 -1
  39. package/src/run/gate-snapshot.js +115 -5
  40. package/src/run/gates.js +35 -5
  41. package/src/run/prompt.js +61 -1
  42. package/src/run/quick-audit.js +80 -4
  43. package/src/run/shared.js +98 -10
  44. package/src/run/verify-quality-scan.js +14 -3
  45. package/src/scope-audit.js +89 -14
  46. package/src/semantic-guard.js +207 -0
  47. package/src/sillyhub-mcp/client.js +14 -0
  48. package/src/spec-sync.js +19 -14
  49. package/src/stage-contract-spec.js +4 -3
  50. package/src/stage-review.js +6 -5
  51. package/src/stages/brainstorm.js +1 -1
  52. package/src/stages/execute.js +6 -1
  53. package/src/stages/plan-postcheck.js +57 -12
  54. package/src/sync.js +66 -4
  55. package/src/task-review.js +41 -6
  56. package/src/verify-facts-schema.js +10 -3
  57. package/src/verify-postcheck.js +21 -6
  58. package/src/verify-probes.js +8 -3
  59. package/src/worktree-apply.js +56 -18
  60. package/src/worktree-cross.js +12 -18
  61. package/src/worktree.js +138 -25
  62. package/src/wt-commit.js +128 -0
@@ -78,6 +78,8 @@ function applyField(entry, label, value) {
78
78
  case 'answer': case '答案': entry.answer = value; break
79
79
  case '锚点': case 'anchor': entry.anchor = value; break
80
80
  case '模块域': case '模块': case 'domains': case 'domain': entry.domains = parseListValue(value); break
81
+ // 文件字段(FR-01/D-001@v1,2026-09-11-cross-change-decision-guard):列表语义同模块域 + 逐项反斜杠归一 POSIX
82
+ case '文件': case 'files': entry.files = parseListValue(value).map(s => s.replace(/\\/g, '/')); break
81
83
  case '否决理由': case 'reject_reason': case 'rejectreason': entry.rejectReason = value; break
82
84
  case '复潮条件': case 'revisit_when': case 'revisitwhen': entry.revisitWhen = value; break
83
85
  case 'supersedes': entry.supersedes = value; break
@@ -93,7 +95,7 @@ function applyField(entry, label, value) {
93
95
  * @returns {{ entries: Array<{
94
96
  * id: string, number: string, version: number, title: string,
95
97
  * type?: string, status?: string, question?: string, answer?: string,
96
- * anchor?: string, domains?: string[], rejectReason?: string, revisitWhen?: string,
98
+ * anchor?: string, domains?: string[], files?: string[], rejectReason?: string, revisitWhen?: string,
97
99
  * supersedes?: string, impacts?: string, normalizedRequirement?: string,
98
100
  * selected: 'implemented'|'rejected'|null, raw: string }>, missing: boolean }}
99
101
  * missing=true = decisions.md 不存在;selected = FR-02 入选裁决,null 表示解析保留但不入选。
@@ -165,7 +167,7 @@ export function parseDecisions(changeDir) {
165
167
  }
166
168
 
167
169
  /** applyField 认识的字段标签白名单(行内 | 分段与缩进子项只按白名单进字段,散文误伤为零) */
168
- const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts|影响|normalized_requirement|状态|类型)$/i
170
+ const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts|影响|文件|files|normalized_requirement|状态|类型)$/i
169
171
 
170
172
  // ---------------------------------------------------------------------------
171
173
  // 域三级兜底(FR-03)
@@ -293,6 +295,8 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
293
295
  lines.push(`状态:${entry.selected}`)
294
296
  if (changeName) lines.push(`变更:${oneLine(changeName)}`)
295
297
  lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
298
+ // 文件行(FR-01/D-001@v1):仅非空渲染——存量条目零迁移,幂等重归档不添空行
299
+ if (entry.files && entry.files.length > 0) lines.push(`文件:${entry.files.join(', ')}`)
296
300
  lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
297
301
  lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
298
302
  if (supersedesNote) lines.push(`supersedes:${oneLine(supersedesNote)}`)
@@ -174,7 +174,14 @@ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, tot
174
174
  console.warn(`[dispatch/probe] probeDaemon 抛异常(保守判 unavailable): ${msg}`);
175
175
  reachable = false;
176
176
  }
177
- if (!reachable) return cacheNegative('daemon-unreachable');
177
+ if (!reachable) {
178
+ // token 失效类型化(2026-09-11 用户实测反馈:吊销/过期 shmcp_ 被报成
179
+ // daemon-unreachable,误导排障——gateway 在、daemon 在,死的只是凭据)。
180
+ // 401 与 unreachable 分诊:进同款负面缓存(TTL 内不重试,token 不会自愈)。
181
+ const initStatus = typeof cli.getLastInitStatus === 'function' ? cli.getLastInitStatus() : null;
182
+ if (initStatus === 401) return cacheNegative('mcp-token-invalid');
183
+ return cacheNegative('daemon-unreachable');
184
+ }
178
185
 
179
186
  // 3.5+4 合并(HUB-12a):一次 tools/list 同时拿 tools 数组(路径A schema 预热)与
180
187
  // root_path(越界校验)。旧 client 无 listToolsWithMeta 时回退 listTools +
package/src/docs-check.js CHANGED
@@ -14,10 +14,95 @@
14
14
  * 仅「目录递归」「目录单层」「字面路径」三形态);Windows 路径归一化;兼容 CRLF/LF。
15
15
  */
16
16
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
17
- import { join } from 'node:path'
17
+ import { join, resolve as pathResolve, relative as pathRelative, sep as pathSep } from 'node:path'
18
+ import { execFileSync } from 'node:child_process'
18
19
  import jsYaml from 'js-yaml'
19
20
  import { parseModuleMapSimple } from './modules.js'
20
21
 
22
+ // ── HEAD 模式内容解析层(坑 docs-gate-shared-worktree-parallel-block,2026-09-08 实证)──
23
+ // pre-push gate 校验的语义对象是「被推送的提交树」,而磁盘工作区混有并行会话的未提交
24
+ // 改动——活文档锚点被在途编辑瞬时漂移,任何会话此时推送都被拦(移动靶,5→7 波动实证)。
25
+ // --against HEAD 模式:文档与源码内容一律取 HEAD 版本——干净文件磁盘直读(与 HEAD
26
+ // 逐字节一致,零额外开销)、脏文件(M/D/R)git show 取 HEAD 内容、未跟踪(??)视为
27
+ // 不存在(不随推送走,本就不该校验)。已知边界(接受并留档):工作区未提交删除的
28
+ // tracked 文档不出现在磁盘 doc 枚举里(walkGlob 扫盘)——其 HEAD 版本确属推送树,
29
+ // 此边角漏检远轻于移动靶误拦;裸名引用仅命中「工作区已删」源文件时层1 找不到候选。
30
+ // 模块级 active reader(set/try/finally restore,quicklog setQuickFileNotes 同款先例):
31
+ // readLines / resolveCandidates / runDocsCheck / collectInvalidDocRefs 的读统一经此层,
32
+ // reader 未激活时行为与磁盘直读逐字节等价(既有调用方/单测零感知)。
33
+ let _headReader = null
34
+ /** 测试与 runDocsCheck 内部使用:激活/恢复 HEAD reader(返回前值供 finally 还原)。 */
35
+ export function _setHeadReaderForCheck(reader) {
36
+ const prev = _headReader
37
+ _headReader = reader
38
+ return prev
39
+ }
40
+
41
+ function _rdText(absPath) {
42
+ if (!_headReader) {
43
+ try { return readFileSync(absPath, 'utf8') } catch { return null }
44
+ }
45
+ return _headReader.readText(absPath)
46
+ }
47
+
48
+ function _rdExists(absPath) {
49
+ if (!_headReader) return existsSync(absPath)
50
+ return _headReader.existsAt(absPath)
51
+ }
52
+
53
+ /**
54
+ * 构造 HEAD 模式 reader。非 git 仓 / projectRoot 非 git 根(porcelain 路径相对仓根,
55
+ * 归一失配)/ git 调用失败 → 返回 null(调用方回退磁盘模式并 warn)。
56
+ * git status 只列差异文件:不在 status 里的 tracked 文件 = 干净(磁盘读即 HEAD 读);
57
+ * ?? 未跟踪 → HEAD 无此文件(existsAt=false, readText=null);M/D/R → HEAD 仍在
58
+ * (existsAt=true),内容经 git show(带缓存,脏文件数量级 = 并行会话在途编辑数)。
59
+ */
60
+ export function createHeadReader(projectRoot, ref = 'HEAD') {
61
+ const root = pathResolve(projectRoot)
62
+ const git = (args) => execFileSync('git', ['-C', root, ...args], {
63
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
64
+ })
65
+ let toplevel
66
+ try { toplevel = git(['rev-parse', '--show-toplevel']).trim() } catch { return null }
67
+ if (toplevel.split(/[\\/]/).join('/') !== root.split(/[\\/]/).join('/')) return null
68
+ const status = new Map() // rel(正斜杠) → 'M' | 'D' | 'U'
69
+ try {
70
+ for (const line of git(['status', '--porcelain', '--', '.']).split(/\r?\n/)) {
71
+ if (!line) continue
72
+ const code = line.slice(0, 2)
73
+ let p = line.slice(3)
74
+ if (code.includes('R')) p = p.split(' -> ').pop() // 重命名记新路径
75
+ p = p.replace(/^"|"$/g, '')
76
+ const st = code.includes('?') ? 'U' : code.includes('D') ? 'D' : 'M'
77
+ status.set(p, st)
78
+ }
79
+ } catch { return null }
80
+ const showCache = new Map()
81
+ const relOf = (abs) => pathRelative(root, pathResolve(abs)).split(pathSep).join('/')
82
+ return {
83
+ ref,
84
+ readText(absPath) {
85
+ const rel = relOf(absPath)
86
+ const st = status.get(rel)
87
+ if (st === undefined) {
88
+ try { return readFileSync(absPath, 'utf8') } catch { return null } // 干净:磁盘即 HEAD
89
+ }
90
+ if (st === 'U') return null // 未跟踪:HEAD 无此文件
91
+ if (showCache.has(rel)) return showCache.get(rel)
92
+ let text = null
93
+ try { text = git(['show', `${ref}:${rel}`]) } catch { text = null }
94
+ showCache.set(rel, text)
95
+ return text
96
+ },
97
+ existsAt(absPath) {
98
+ const st = status.get(relOf(absPath))
99
+ if (st === 'U') return false // 未跟踪 → HEAD 不存在
100
+ if (st === 'D' || st === 'M') return true // 工作区删/改 → HEAD 仍在
101
+ return existsSync(absPath)
102
+ },
103
+ }
104
+ }
105
+
21
106
  /** 提取 file.js:line / file.js:start-end 引用(反引号包裹与裸文本均命中,全文扫描 D-006)。
22
107
  * 扩展名覆盖(archify 借鉴 P1b,2026-09-04):js/mjs/cjs/ts/tsx/jsx/py/java/go——
23
108
  * scan 产物面向任意技术栈项目(如 SillyHub = Python + TypeScript),只认 .js 会让
@@ -268,16 +353,19 @@ function findInTree(dir, baseName, rel = '', treeCache = null) {
268
353
  export function resolveCandidates(projectRoot, refFile, treeCache = null) {
269
354
  if (refFile.includes('/')) {
270
355
  const direct = join(projectRoot, refFile)
271
- if (existsSync(direct)) return [direct]
356
+ if (_rdExists(direct)) return [direct]
272
357
  const inSrc = join(projectRoot, 'src', refFile)
273
- if (existsSync(inSrc)) return [inSrc]
358
+ if (_rdExists(inSrc)) return [inSrc]
274
359
  const slash = refFile.lastIndexOf('/')
275
360
  const baseName = slash === -1 ? refFile : refFile.slice(slash + 1)
276
361
  return findInTree(join(projectRoot, 'src'), baseName, '', treeCache)
277
362
  .filter((rel) => ('src/' + rel).endsWith('/' + refFile) || 'src/' + rel === 'src/' + refFile)
278
363
  .map((rel) => join(projectRoot, 'src', rel))
364
+ .filter((abs) => _rdExists(abs)) // HEAD 模式:未跟踪源文件不进推送树,不作候选
279
365
  }
280
- return findInTree(join(projectRoot, 'src'), refFile, '', treeCache).map((rel) => join(projectRoot, 'src', rel))
366
+ return findInTree(join(projectRoot, 'src'), refFile, '', treeCache)
367
+ .map((rel) => join(projectRoot, 'src', rel))
368
+ .filter((abs) => _rdExists(abs))
281
369
  }
282
370
 
283
371
  /**
@@ -301,9 +389,8 @@ export function collectInvalidDocRefs(projectRoot, docEntries) {
301
389
  const linesCache = new Map()
302
390
  for (const { name, absPath } of docEntries) {
303
391
  let content
304
- try {
305
- content = readFileSync(absPath, 'utf8')
306
- } catch {
392
+ content = _rdText(absPath)
393
+ if (content === null) {
307
394
  readFailures++
308
395
  continue
309
396
  }
@@ -355,7 +442,9 @@ function readLines(absPath, linesCache = null) {
355
442
  return lines
356
443
  }
357
444
  try {
358
- const lines = readFileSync(absPath, 'utf8').split(/\r?\n/)
445
+ const text = _rdText(absPath)
446
+ if (text === null) return null
447
+ const lines = text.split(/\r?\n/)
359
448
  if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
360
449
  return lines
361
450
  } catch { return null }
@@ -479,6 +568,28 @@ function relDisplay(absPath, projectRoot) {
479
568
  * @throws {DocsCheckConfigError} glob 形态不支持
480
569
  */
481
570
  export function runDocsCheck(opts) {
571
+ // --against <ref>(坑 docs-gate-shared-worktree-parallel-block):pre-push gate 校验
572
+ // 「被推送的提交树」而非工作区——激活 HEAD reader 隔离并行会话在途编辑(移动靶)。
573
+ // 非 git 仓/子目录/git 异常 → 回退磁盘模式并附 warning。--fix 路径不经此(applyFixes
574
+ // 恒操作工作区磁盘内容,against 对 fix 无意义,CLI 层已拒绝组合)。
575
+ const against = opts && opts.against ? String(opts.against) : null
576
+ if (!against) return _runDocsCheckCore(opts)
577
+ const reader = createHeadReader(opts.projectRoot, against)
578
+ if (!reader) {
579
+ const r = _runDocsCheckCore(opts)
580
+ r.warnings = [...(r.warnings || []),
581
+ `--against ${against} 不可用(非 git 仓 / 非 git 根子目录 / git 异常),已回退磁盘模式——并行在途编辑可能造成移动靶误拦`]
582
+ return r
583
+ }
584
+ const prev = _setHeadReaderForCheck(reader)
585
+ try {
586
+ return _runDocsCheckCore(opts)
587
+ } finally {
588
+ _setHeadReaderForCheck(prev)
589
+ }
590
+ }
591
+
592
+ function _runDocsCheckCore(opts) {
482
593
  // paths/docs 显式传 null(readDocsCheckConfig 无 local.yaml 段时的回退值)须落回缺省 glob,
483
594
  // 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
484
595
  const {
@@ -511,7 +622,10 @@ export function runDocsCheck(opts) {
511
622
 
512
623
  for (const docRel of docFiles) {
513
624
  const docAbs = join(projectRoot, docRel)
514
- if (!existsSync(docAbs)) {
625
+ // HEAD 模式:未跟踪文档(walkGlob 扫盘枚举到但不随推送走)静默跳过——不是失效,
626
+ // 也不该以「文档不存在」入 invalid(它存在于工作区,只是不属于被推送的提交树)。
627
+ if (_headReader && !_rdExists(docAbs)) { skippedExempt++; continue }
628
+ if (!_rdExists(docAbs)) {
515
629
  invalid.push({
516
630
  doc: docRel, docLine: 0, ref: '', reason: '文档不存在',
517
631
  fix: { fixable: false, reason: '文档不存在,无法自动定位' },
@@ -521,7 +635,16 @@ export function runDocsCheck(opts) {
521
635
  // 豁免双通道(FR-3 / D-003):路径段判定免 IO(readFileSync 前);
522
636
  // frontmatter 判定需读文件(读取后)。豁免文档整体跳过不计 invalid。
523
637
  if (exempt && isExemptDocPath(docRel)) { skippedExempt++; continue }
524
- const md = readFileSync(docAbs, 'utf8')
638
+ const md = _rdText(docAbs)
639
+ if (md === null) {
640
+ // HEAD 模式下未跟踪文档(不随推送走,walkGlob 扫盘枚举到此)——不计失效不报警
641
+ if (_headReader) { skippedExempt++; continue }
642
+ invalid.push({
643
+ doc: docRel, docLine: 0, ref: '', reason: '文档不可读',
644
+ fix: { fixable: false, reason: '文档不可读(权限/路径异常)' },
645
+ })
646
+ continue
647
+ }
525
648
  if (exempt && isExemptDocFrontmatter(md)) { skippedExempt++; continue }
526
649
  const refs = collectDocRefs(md)
527
650
  const mdLines = md.split(/\r?\n/)
@@ -836,10 +959,12 @@ export const DECISIONS_DEFAULT_BEHIND_THRESHOLD = 10
836
959
  * @param {string} yamlText local.yaml 全文
837
960
  * @returns {string[]} 键列表;无声明返回 []
838
961
  */
839
- function extractKnownFailureKeys(yamlText) {
962
+ export function extractKnownFailureKeys(yamlText) {
840
963
  if (!yamlText) return []
841
964
  const yaml = String(yamlText).replace(/\r\n?/g, '\n')
842
- const inline = yaml.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
965
+ // 贪婪 .* 同 verify-postcheck.extractKnownFailures(坑 known-failures-inline-nested-bracket:
966
+ // 值内 ] 使 [^\]]* 截断、整表静默清空;两处复刻必须同口径互指)
967
+ const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
843
968
  if (inline) {
844
969
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
845
970
  }
package/src/docs-gate.js CHANGED
@@ -77,6 +77,9 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
77
77
  skip: checkOpts.skip || cfg.skip,
78
78
  keywordAssert: checkOpts.keywordAssert ?? cfg.keywordAssert,
79
79
  crossRepoRoots: checkOpts.crossRepoRoots || cfg.crossRepoRoots,
80
+ // 坑 docs-gate-shared-worktree-parallel-block:--against <ref> 透传——按提交树校验
81
+ //(pre-push 语义),隔离并行会话在途编辑的锚点瞬时漂移(移动靶)
82
+ ...(checkOpts.against ? { against: checkOpts.against } : {}),
80
83
  });
81
84
  } catch (e) {
82
85
  if (e instanceof DocsCheckConfigError) {
@@ -856,7 +856,7 @@ export function detectMcpEndpoints(cwd) {
856
856
  * _module-map.yaml 的 needs_review=true 清单(卡缺失/精确校验归 modules rebuild/status)。
857
857
  * 只读 fail-soft。
858
858
  */
859
- function detectModuleDocHealth(cwd) {
859
+ export function detectModuleDocHealth(cwd) {
860
860
  const base = { name: 'module_doc_health', label: '模块文档健康', safe_actions: [] }
861
861
  try {
862
862
  let mapText = null
@@ -870,7 +870,8 @@ function detectModuleDocHealth(cwd) {
870
870
  if (!mapText) return { ...base, findings: ['无 _module-map.yaml(未 scan)——skipped'], pass: true, severity: null, skipped: true }
871
871
  const findings = []
872
872
  // needs_review 清单(模块块内 needs_review: true)
873
- const blocks = mapText.split(/\n(?= \\w)/)
873
+ // \\w 是「字面反斜杠+w」——永不分割,整文件当一块只查首个模块(ql-20260911-029)
874
+ const blocks = mapText.split(/\n(?= \w)/)
874
875
  for (const blk of blocks) {
875
876
  const idM = blk.match(/^ ([\w.-]+):/)
876
877
  if (idM && /needs_review:\s*true/.test(blk)) {
@@ -999,13 +1000,24 @@ function detectLifecycleDocStaleness(cwd) {
999
1000
  return { ...base, pass: true, severity: null, findings: ['文档时间戳不可解析,跳过(不误报)'] };
1000
1001
  }
1001
1002
  // 生命周期代码侧最新提交(取各敏感路径 max %ct;任一路径 untracked 新文件不在 log 里,
1002
- // git 不可见性注记——工作树未提交改动本就不该由 doctor 报,提交后自然可见)
1003
+ // git 不可见性注记——工作树未提交改动本就不该由 doctor 报,提交后自然可见)。
1004
+ // 批 E-②:单次多 pathspec git log 取 max(原逐路径 6 个串行子进程,Windows ~0.4-1s);
1005
+ // 仅当检出「文档落后」需要归因到具体路径时,才退回逐路径解析(罕见告警路径)。
1003
1006
  let srcTs = 0;
1004
1007
  let srcPath = null;
1005
- for (const p of LIFECYCLE_SENSITIVE_PATHS) {
1006
- const r = safeGit(cwd, ['log', '-1', '--format=%ct', '--', p]);
1007
- const ts = Number(r.value);
1008
- if (Number.isFinite(ts) && ts > srcTs) { srcTs = ts; srcPath = p; }
1008
+ const combined = safeGit(cwd, ['log', '-1', '--format=%ct', '--', ...LIFECYCLE_SENSITIVE_PATHS]);
1009
+ srcTs = Number(combined.value);
1010
+ if (!Number.isFinite(srcTs) || srcTs <= 0) srcTs = 0;
1011
+ if (docTs < srcTs) {
1012
+ // 归因:找 ts === srcTs 的路径(等不到就取最大有限值兜底)
1013
+ let bestTs = 0
1014
+ for (const p of LIFECYCLE_SENSITIVE_PATHS) {
1015
+ const r = safeGit(cwd, ['log', '-1', '--format=%ct', '--', p]);
1016
+ const ts = Number(r.value);
1017
+ if (!Number.isFinite(ts)) continue;
1018
+ if (ts === srcTs) { srcPath = p; break; }
1019
+ if (ts > bestTs) { bestTs = ts; srcPath = p; }
1020
+ }
1009
1021
  }
1010
1022
  if (srcTs === 0) {
1011
1023
  return { ...base, pass: true, severity: null, findings: ['生命周期代码无提交历史,跳过'] };
@@ -26,6 +26,7 @@
26
26
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
27
27
  import { dirname, join } from 'path'
28
28
  import { writeAtomicSync } from './fs-atomic.js'
29
+ import { withFileLock } from './quicklog.js'
29
30
  import { resolveRuntimeRoot, resolveQuickSessionsDir } from './run/shared.js'
30
31
 
31
32
  /** 摩擦事件类型枚举(D-004):审查打回走专属 review_rejected,不与 gate_rollback 重复计 */
@@ -169,13 +170,14 @@ export function renderFrictionHintLine(counts) {
169
170
  * @param {string} [p.detail] 预定义来源标签(如 verify-test/quick-audit),非字符串落 null
170
171
  * @returns {{ type: string, count: number } | null} 落盘成功返回新计数;null = 未落盘
171
172
  */
172
- export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
173
+ export async function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
173
174
  try {
174
175
  if (!FRICTION_TYPES.has(type)) return null
175
176
  const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
176
177
  if (!tallyPath) return null
177
178
  // enabled=false 直通:record/consume 双 no-op,.runtime 零写入(D-003 一键全关)
178
179
  if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return null
180
+ return await withFileLock(tallyPath + ".lock", async () => {
179
181
  const tally = readFrictionTally(tallyPath)
180
182
  const at = new Date().toISOString()
181
183
  const prev = Number(tally.events[type] && tally.events[type].count)
@@ -188,6 +190,7 @@ export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detai
188
190
  mkdirSync(dirname(tallyPath), { recursive: true })
189
191
  writeAtomicSync(tallyPath, JSON.stringify(tally, null, 2))
190
192
  return { type, count }
193
+ })
191
194
  } catch {
192
195
  return null
193
196
  }
@@ -201,11 +204,12 @@ export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detai
201
204
  * 会再消费一次,advisory 容忍重复,不因删失败丢提示。
202
205
  * @returns {{ hint: string | null, counts: Record<string, number> }}
203
206
  */
204
- export function consumeFrictionHint({ cwd, platformOpts, changeName }) {
207
+ export async function consumeFrictionHint({ cwd, platformOpts, changeName }) {
205
208
  try {
206
209
  const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
207
210
  if (!tallyPath || !existsSync(tallyPath)) return { hint: null, counts: {} }
208
211
  if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return { hint: null, counts: {} }
212
+ return await withFileLock(tallyPath + ".lock", async () => {
209
213
  const tally = readFrictionTally(tallyPath)
210
214
  const counts = {}
211
215
  for (const type of FRICTION_TYPES) {
@@ -215,6 +219,7 @@ export function consumeFrictionHint({ cwd, platformOpts, changeName }) {
215
219
  if (Object.keys(counts).length === 0) return { hint: null, counts }
216
220
  try { rmSync(tallyPath, { force: true }) } catch { /* 删失败不吞提示 */ }
217
221
  return { hint: renderFrictionHintLine(counts), counts }
222
+ })
218
223
  } catch {
219
224
  return { hint: null, counts: {} }
220
225
  }
package/src/git-helper.js CHANGED
@@ -124,16 +124,18 @@ export function safeGit(cwd, args, opts = {}) {
124
124
  * @returns {string|Buffer}
125
125
  */
126
126
  export function git(cwd, args, opts = {}) {
127
- const { trim = true, timeout = 5000, encoding = 'utf8' } = opts
127
+ const { trim = true, timeout = 5000, encoding = 'utf8', env } = opts
128
128
  const fullArgs = buildFullArgs(cwd, args)
129
- const value = execFileSync('git', fullArgs, { encoding, timeout, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'] })
129
+ // env(终批-③):可选注入(如 baseline checkpoint 的临时 GIT identity)——调用方必须
130
+ // 用 { ...process.env, GIT_xxx } 展开形式,裸替换会丢 SystemRoot/USERPROFILE/TEMP(Windows)
131
+ const value = execFileSync('git', fullArgs, { encoding, timeout, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'], env })
130
132
  if (Buffer.isBuffer(value)) return value
131
133
  return trim ? value.trim() : value
132
134
  }
133
135
 
134
136
  /**
135
137
  * 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
136
- * ENOBUFS(输出超 32MB)不是"无输出",静默成 null 会让调用方把大输出误当空清单——
138
+ * ENOBUFS(输出超 GIT_MAX_BUFFER 上限)不是"无输出",静默成 null 会让调用方把大输出误当空清单——
137
139
  * 至少 stderr 留一行,不与正常空结果同语义。
138
140
  * @param {string} cwd
139
141
  * @param {string[]} args
@@ -145,7 +147,7 @@ export function gitQuiet(cwd, args, opts = {}) {
145
147
  return git(cwd, args, opts)
146
148
  } catch (e) {
147
149
  if (e && e.code === 'ENOBUFS') {
148
- console.error(`[sillyspec] git 输出超过 32MB 上限被截断:git ${args.join(' ')}`)
150
+ console.error(`[sillyspec] git 输出超过 ${GIT_MAX_BUFFER / (1024 * 1024)}MB 上限被截断:git ${args.join(' ')}`)
149
151
  }
150
152
  return null
151
153
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { existsSync, readFileSync, readdirSync } from 'fs'
13
13
  import path from 'path'
14
- import { execFileSync } from 'child_process'
14
+ import { createRequire } from 'node:module'
15
15
 
16
16
  // ── 常量 ──
17
17
 
@@ -29,8 +29,9 @@ const READONLY_COMMANDS = new Set([
29
29
  'node', 'npm', 'npx', // 只允许 --version 等只读子命令,在 matchReadonlyWhitelist 中处理
30
30
  ])
31
31
 
32
- /** 只读 git 子命令(stash 不在此列:drop/clear/pop 破坏性,仅下方 matchReadonly 细化放行 list/裸查询) */
33
- const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show', 'branch'])
32
+ /** 只读 git 子命令(stash/branch/worktree 不在此列:branch 的 -d/-D/-m/-M 破坏性、worktree
33
+ * 的 remove --force 可删未提交工作区——各自在下方按参数细化放行) */
34
+ const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show'])
34
35
 
35
36
  /** 危险 git 子命令 */
36
37
  const DANGER_GIT_SUBS = new Set([
@@ -38,6 +39,14 @@ const DANGER_GIT_SUBS = new Set([
38
39
  'mv', 'rm',
39
40
  ])
40
41
 
42
+ /** git branch 的破坏性 flag(-d/-D 删分支、-m/-M 改名、-f 强建重指;含长形同义)——
43
+ * 删/改分支会让 task review 的 base/head ref 引用悬空(gc 后真丢),2026-09-11 审查 P1 收口 */
44
+ const BRANCH_DESTRUCTIVE_RE = /^(?:-[dDmMf]|--delete|--force|--move|--edit-description)(?:=|$)/
45
+
46
+ function hasDestructiveBranchFlag(parts) {
47
+ return parts.slice(2).some(tok => BRANCH_DESTRUCTIVE_RE.test(tok))
48
+ }
49
+
41
50
  /** 危险 git stash 操作 */
42
51
  const DANGER_STASH_ACTIONS = new Set(['drop', 'clear', 'pop'])
43
52
 
@@ -235,9 +244,9 @@ function isInsideWorktreeStorage(filePath, cwd) {
235
244
  /**
236
245
  * 同步只读查询 sillyspec.db 第一行第一列(不依赖外部 sqlite3 CLI)。
237
246
  *
238
- * 用子进程跑 node:sqlite 的 DatabaseSync(execFileSync 等待):
239
- * - node:sqlite 是 Node 内置模块,子进程 require('node:sqlite') 直接可用,无需先 resolve 出
240
- * 绝对路径再注入(旧方案需手动解析原生绑定路径,node:sqlite 无此步骤)。
247
+ * 进程内 createRequire 同步加载 node:sqlite 的 DatabaseSync(2026-09-11 审查性能包②:旧实现
248
+ * 每次查询 spawn 一个 node 子进程,Windows 100-300ms/次,hook 每次 Write/Edit/Bash 触发 1-2 次;
249
+ * hook 自身就是同一 node 二进制,子进程无隔离收益只有延迟):
241
250
  * - readOnly 打开只读连接(替代旧方案 readonly/fileMustExist 语义):绝不写库;WAL 并发读不阻塞主进程写,
242
251
  * 且只读连接可看到已 commit 但未 checkpoint 的过渡态(修复旧 WASM 内存库裸读 .db
243
252
  * 看不见 WAL 的过渡态可见性窗口)。db 缺失/损坏由 open 异常自然触发 fail-closed(外层 catch)。
@@ -245,7 +254,7 @@ function isInsideWorktreeStorage(filePath, cwd) {
245
254
  * 环境假设(R-09):WAL 模式下只读连接仍需在 .runtime/ 目录建/更新 -shm 索引文件,故 .runtime
246
255
  * 必须可写(与主进程一致)——“只读”指不改 DB 数据,不等于不对 -shm 加索引。
247
256
  *
248
- * fail-closed:db 文件不存在 / 子进程打开、查询异常 / 子进程超时或崩溃——一律 console.warn
257
+ * fail-closed:db 文件不存在 / 打开、查询异常 / node:sqlite 不可用——一律 console.warn
249
258
  * (含 e.stderr 详情)并返回 null,调用方(readCurrentStage/isNoWorktreeMode)对 null 走
250
259
  * fail-closed,禁止 fail-open。
251
260
  */
@@ -257,26 +266,26 @@ function queryDbFirstCell(cwd, sql) {
257
266
  console.warn('⚠️ sillyspec.db 不存在,hook 降级 fail-closed: ' + dbPath)
258
267
  return null
259
268
  }
260
- // 子进程同步内置 require('node:sqlite')(DatabaseSync 是同步 API,无 async/import().then 包装)。
261
- // prepare(sql).get() 取第一行整行后取首列值,无行返回 undefined(写空 stdout → 父侧 trim 后 null)。
262
- const script =
263
- "const {DatabaseSync}=require('node:sqlite')," +
264
- "dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ";" +
265
- "let db;" +
266
- "try{db=new DatabaseSync(dbPath,{readOnly:true});" +
267
- "const row=db.prepare(sql).get();" +
268
- "const v=row===undefined?undefined:Object.values(row)[0];" +
269
- "if(v!==undefined)process.stdout.write(String(v??''));db.close()}" +
270
- "catch(e){process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)};"
269
+ // 进程内同步 require node:sqlite(DatabaseSync 同步 API;2026-09-11 审查性能包②:旧实现每次
270
+ // hook 触发 spawn 一个 node 子进程查库,Windows 100-300ms/次×每 Write/Edit/Bash 1-2 次——
271
+ // hook 自身就是同一 node 二进制,子进程无隔离收益只有延迟)。异常路径与旧子进程版同语义:
272
+ // 捕获后 warn + 返回 null,调用方(readCurrentStage 等)对 null 走 fail-closed。
271
273
  try {
272
- return execFileSync(process.execPath, ['-e', script], {
273
- encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
274
- }).trim() || null
274
+ const req = createRequire(import.meta.url)
275
+ const { DatabaseSync } = req('node:sqlite')
276
+ let db
277
+ try {
278
+ db = new DatabaseSync(dbPath, { readOnly: true })
279
+ const row = db.prepare(sql).get()
280
+ const v = row === undefined ? undefined : Object.values(row)[0]
281
+ return v === undefined ? null : (String(v ?? '') || null)
282
+ } finally {
283
+ if (db) db.close()
284
+ }
275
285
  } catch (e) {
276
- // DB 查询失败(db 损坏 / readOnly 打开失败 / 子进程超时或崩溃):降级返回 null,调用方
277
- //(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因,
278
- // 而非完全静默(子进程把诊断写到了 stderr,execFileSync 抛错时挂在 e.stderr 上)。
279
- const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
286
+ // DB 查询失败(db 损坏 / readOnly 打开失败 / node:sqlite 不可用):降级返回 null,调用方
287
+ //(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因。
288
+ const detail = (e && e.message) ? e.message : String(e)
280
289
  console.warn('⚠️ sillyspec.db 查询失败,hook 降级 fail-closed: ' + detail)
281
290
  return null
282
291
  }
@@ -532,10 +541,13 @@ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
532
541
  if (cmdName === 'git') {
533
542
  const sub = parts[1] || ''
534
543
  if (READONLY_GIT_SUBS.has(sub)) return true
544
+ // git branch:裸查询/建分支放行;-d/-D/-m/-M/-f(含长形)破坏性不放行(按参数细化,不再整类放行)
545
+ if (sub === 'branch' && !hasDestructiveBranchFlag(parts)) return true
535
546
  // git stash list
536
547
  if (sub === 'stash' && (parts[2] === 'list' || parts.length === 2)) return true
537
- // git worktree 管理(list/add/remove)放行
538
- if (sub === 'worktree') return true
548
+ // git worktree:仅 list(裸 worktree 同 list)。remove --force 可删含未提交改动的工作区、
549
+ // 且 junction 会穿透删主仓 node_modules——add/remove/prune 一律走 sillyspec worktree 命令(内建护栏)
550
+ if (sub === 'worktree' && (parts.length === 2 || parts[2] === 'list')) return true
539
551
  return false
540
552
  }
541
553
 
@@ -563,6 +575,10 @@ function isSingleCommandDangerous(cmd) {
563
575
  if (cmdName === 'git') {
564
576
  const sub = parts[1] || ''
565
577
  if (DANGER_GIT_SUBS.has(sub)) return true
578
+ // git branch -d/-D/-m/-M/-f(含长形):删/改分支让 task review 的 base/head 引用悬空
579
+ if (sub === 'branch' && hasDestructiveBranchFlag(parts)) return true
580
+ // git worktree remove/prune:删工作区(可能含未提交改动)——清理走 sillyspec worktree cleanup
581
+ if (sub === 'worktree' && ['remove', 'prune'].includes(parts[2] || '')) return true
566
582
  // git stash drop/clear/pop
567
583
  if (sub === 'stash' && DANGER_STASH_ACTIONS.has(parts[2] || '')) return true
568
584
  }
@@ -603,6 +619,20 @@ function matchReadonlyWhitelist(command, extraReadonlyCommands = []) {
603
619
  * @param {string} command
604
620
  * @returns {boolean}
605
621
  */
622
+ /**
623
+ * 危险拦截原因的定向指引(branch/worktree 的破坏形态有官方替代路径,提示同步到拦截消息)
624
+ */
625
+ function dangerBlockHint(command) {
626
+ const parts = String(command).trim().toLowerCase().split(/\s+/)
627
+ if (parts[0] === 'git' && parts[1] === 'branch') {
628
+ return '——分支删除/改名/强建会让 task review 的 base/head 引用悬空;分支管理走 sillyspec worktree 流程'
629
+ }
630
+ if (parts[0] === 'git' && parts[1] === 'worktree') {
631
+ return '——worktree 清理走 sillyspec worktree cleanup(内建未落仓护栏),勿裸删工作区'
632
+ }
633
+ return ''
634
+ }
635
+
606
636
  function matchDangerBlacklist(command) {
607
637
  const parts = splitCommandParts(command)
608
638
  return parts.some(p => isSingleCommandDangerous(p))
@@ -825,7 +855,7 @@ function shouldBlockBash(command, cwd) {
825
855
  if (stage === 'quick') {
826
856
  // 危险黑名单仍然拦截
827
857
  if (matchDangerBlacklist(command)) {
828
- return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
858
+ return { blocked: true, reason: `dangerous command blocked: ${command.trim()}${dangerBlockHint(command)}` }
829
859
  }
830
860
  // 检查命令是否会覆盖 baseline files(并集 = 保护所有 session 的 baseline)
831
861
  const { baselineFiles } = readAllQuickGuards(projectRoot)
@@ -843,7 +873,7 @@ function shouldBlockBash(command, cwd) {
843
873
 
844
874
  // 危险黑名单
845
875
  if (matchDangerBlacklist(command)) {
846
- return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
876
+ return { blocked: true, reason: `dangerous command blocked: ${command.trim()}${dangerBlockHint(command)}` }
847
877
  }
848
878
 
849
879
  // 只读白名单放行