sillyspec 3.28.4 → 3.28.6

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 (52) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/sa-rename-probe/src/new.js +4 -0
  5. package/src/agent-session-log.js +4 -3
  6. package/src/archive-delta.js +2 -2
  7. package/src/change-list.js +11 -1
  8. package/src/commit-suggest.js +52 -1
  9. package/src/config-cat.js +6 -1
  10. package/src/config-schema.js +13 -0
  11. package/src/decision-distill.js +53 -7
  12. package/src/decisions-io.js +96 -0
  13. package/src/design-facts.js +3 -0
  14. package/src/dispatch/probe.js +31 -8
  15. package/src/docs-check.js +77 -8
  16. package/src/friction-tally.js +221 -0
  17. package/src/index.js +430 -18
  18. package/src/mcp-server.js +144 -0
  19. package/src/module-impact.js +8 -1
  20. package/src/modules.js +46 -34
  21. package/src/progress/change-registry.js +20 -0
  22. package/src/progress.js +16 -1
  23. package/src/quicklog.js +60 -6
  24. package/src/review-dispatch.js +377 -0
  25. package/src/run/archive-distill.js +48 -0
  26. package/src/run/command.js +35 -5
  27. package/src/run/complete-handlers.js +136 -3
  28. package/src/run/complete.js +170 -2
  29. package/src/run/gate-snapshot.js +159 -0
  30. package/src/run/gates.js +166 -69
  31. package/src/run/prompt.js +54 -9
  32. package/src/run/quick-audit.js +78 -16
  33. package/src/run/shared.js +138 -7
  34. package/src/run/stage.js +31 -2
  35. package/src/run/verify-quality-scan.js +383 -0
  36. package/src/scope-audit.js +941 -0
  37. package/src/sillyhub-mcp/client.js +58 -3
  38. package/src/stage-review-checklist.js +44 -0
  39. package/src/stage-review.js +122 -4
  40. package/src/stages/archive.js +57 -164
  41. package/src/stages/brainstorm.js +8 -12
  42. package/src/stages/execute.js +13 -10
  43. package/src/stages/plan.js +3 -10
  44. package/src/stages/verify.js +14 -33
  45. package/src/sync.js +51 -10
  46. package/src/task-review.js +108 -16
  47. package/src/taskcard.js +46 -3
  48. package/src/validate-artifacts.js +163 -0
  49. package/src/verify-postcheck.js +90 -10
  50. package/src/verify-probes.js +134 -12
  51. package/src/worktree-apply.js +119 -14
  52. package/templates/agents-instruction.md +1 -0
package/src/docs-check.js CHANGED
@@ -28,16 +28,22 @@ 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。
31
+ * 正则组:1=仓库名(可选),2=文件,3=start,4=end,5=纯位置锚标记(可选尾 `?`)。
32
+ * 纯位置锚 `file.js:123?`(2026-09-11 用户实证 docs gate 误伤:跨文件引用+反话论述——锚 A
33
+ * 文件而行内反引号 token 全是 B 概念,层 2 必失败,被迫删行号绕开):`?` 显式声明本锚只做
34
+ * 定位不做关键词断言,层 1(存在性+行界)照校——论述语境锚的一等语法,替代行号化绕开。
32
35
  * 文件段展开循环形(D-006,2026-09-08-docs-fix-capability):普通段可选 + 零或多个「闭合括号段+普通段」
33
36
  * 迭代——支持 Next.js 路由组 (dashboard) 等括号路径;markdown 链接 [t](foo.js:12) 在 `(` 处括号段
34
37
  * 要求 `)` 先于 `:` 闭合而失败,回落 foo.js:12 零回归;嵌套 ((x)) 部分提取(与旧行为一致)。
35
38
  * ⚠ 不用原子序列 (?:A+|B+)+——经典嵌套量词 ReDoS(Grill 实证 n=30 长 token 73.8s),
36
39
  * 展开循环每次迭代必含括号段 → 划分唯一 → 线性。 */
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
40
+ 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
38
41
 
39
- /** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八) */
40
- export const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
42
+ /** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八)
43
+ * + .sillyspec/changes/ + .sillyspec/knowledge/(P1-1,noai-ir-roadmap §4:最活跃、最易产生
44
+ * 漂移引用的变更目录与决策库此前零校验——纳入后 verify/archive 收尾的 autoReanchorDocRefs
45
+ * 同时覆盖变更文档(archive/ 路径段与 doc_type: snapshot 双通道豁免不变,归档件不回检)。 */
46
+ export const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md', '.sillyspec/changes/**/*.md', '.sillyspec/knowledge/**/*.md']
41
47
 
42
48
  /**
43
49
  * 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
@@ -73,6 +79,32 @@ export function readDocsCheckConfig(projectRoot) {
73
79
  /** 配置错误(glob 形态不支持等)→ CLI exit 2 */
74
80
  export class DocsCheckConfigError extends Error {}
75
81
 
82
+ /**
83
+ * 符号锚正则(P1-2,noai-ir-roadmap §4):`file.js::symbol` 形态——校验时解析符号定义行,
84
+ * 天然免疫行号漂移(重锚从「每次代码移动」降为「仅改名时」)。文件段与 REF_RE 同款(含
85
+ * repo:// 跨仓前缀与括号路径组展开),`::` 后只认 ASCII 标识符(自然语言/中文括注不触发,
86
+ * QUICKLOG file-notes 的 `path::注` 约定因注解非标识符而天然互斥)。与 REF_RE 互斥:
87
+ * `::ident` 无数字不匹配行号式;`file.js:12` 无 `::` 不匹配符号式。
88
+ */
89
+ 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
90
+
91
+ /**
92
+ * 符号定义行定位(与 classifyFix 的 defRe 同款形态,单一判定口径):function/const/let/
93
+ * class/def(含 export/async 前缀)后的精确符号名。命中返回 1-based 行号;文件内仅
94
+ * 「使用」未「定义」(import 消费/注释提及)不算命中——符号锚指向定义文件。
95
+ * @param {string[]} lines 源文件行数组
96
+ * @param {string} symbol 符号名(须为合法标识符)
97
+ * @returns {number|null}
98
+ */
99
+ export function findSymbolDefLine(lines, symbol) {
100
+ if (!Array.isArray(lines) || !symbol || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(symbol)) return null
101
+ const defRe = new RegExp(`(?:export\\s+)?(?:async\\s+)?(?:function\\*?\\s+|class\\s+|(?:const|let|var)\\s+|def\\s+)${escapeReLocal(symbol)}\\b`)
102
+ for (let i = 0; i < lines.length; i++) {
103
+ if (defRe.test(lines[i])) return i + 1
104
+ }
105
+ return null
106
+ }
107
+
76
108
  /**
77
109
  * 纯函数:从 markdown 全文提取全部 file:line 引用。
78
110
  * @param {string} md 文档全文
@@ -98,9 +130,26 @@ export function collectDocRefs(md) {
98
130
  file: m[2],
99
131
  start: parseInt(m[3], 10),
100
132
  end: m[4] !== undefined ? parseInt(m[4], 10) : parseInt(m[3], 10),
133
+ kwSkip: m[5] === '?', // 纯位置锚(file.js:123?):层1 照校、层2 跳过
101
134
  docLine: line,
102
135
  })
103
136
  }
137
+ // 符号锚引用(P1-2):独立正则二遍扫描(与行号式互斥,无重叠去重需求)。
138
+ // 符号式引用量级远小于行号式,docLine 逐个 O(index) 计算不构成退化面。
139
+ const symRe = new RegExp(SYMBOL_REF_RE.source, 'g')
140
+ let sm
141
+ while ((sm = symRe.exec(md)) !== null) {
142
+ refs.push({
143
+ ref: sm[0],
144
+ repo: sm[1] !== undefined ? sm[1] : null,
145
+ file: sm[2],
146
+ symbol: sm[3],
147
+ kind: 'symbol',
148
+ start: null,
149
+ end: null,
150
+ docLine: md.slice(0, sm.index).split('\n').length,
151
+ })
152
+ }
104
153
  return refs
105
154
  }
106
155
 
@@ -267,7 +316,7 @@ export function collectInvalidDocRefs(projectRoot, docEntries) {
267
316
  invalid.push({ doc: name, ref: ref.ref, reason: '文件不存在' })
268
317
  continue
269
318
  }
270
- const tokens = extractExpectedTokensFromLine(docLines[ref.docLine - 1] || '')
319
+ const tokens = ref.kwSkip ? [] : extractExpectedTokensFromLine(docLines[ref.docLine - 1] || '')
271
320
  const infos = candidates.map(abs => ({ lines: readLines(abs, linesCache) }))
272
321
  const ok = infos.some(({ lines }) => {
273
322
  if (!lines || lines.length === 0) return false
@@ -452,6 +501,8 @@ export function runDocsCheck(opts) {
452
501
  let skippedExempt = 0
453
502
  // 模糊路径引用数(含 `...` 省略号,跳过校验——FR-1.2)
454
503
  let skippedFuzzy = 0
504
+ // 符号锚(P1-2)命中数(计入 total,单独计数供可见性)
505
+ let symbolChecked = 0
455
506
  // per-call 缓存:裸名引用的 src/ 全树扫描结果 + 候选文件行数组——100 文档 × 10 裸名引用
456
507
  // 此前 = 1000 次全树 walk + 同文件 N 次重读(2026-08-21 性能审查 PERF-5)。仅本次调用
457
508
  // 生命周期内有效,不跨调用(防测试流程中途改树后读到陈旧结果)
@@ -522,8 +573,26 @@ export function runDocsCheck(opts) {
522
573
  })
523
574
  continue
524
575
  }
576
+ // 符号锚(P1-2):按「候选文件内存在符号定义行」判定——不校验行号(校验时解析,
577
+ // 行漂移天然免疫),层 2 关键词断言由符号本身命中自动满足。失效只可能是符号拼错/
578
+ // 文件归属错/符号被删改名——均非重锚能修,fix 显式不可自动。
579
+ if (r.kind === 'symbol') {
580
+ let hit = false
581
+ for (const candAbs of candidates) {
582
+ const lines = readLines(candAbs, linesCache)
583
+ if (lines === null) continue
584
+ if (findSymbolDefLine(lines, r.symbol) !== null) { hit = true; break }
585
+ }
586
+ if (hit) { symbolChecked++; continue }
587
+ invalid.push({
588
+ doc: docRel, docLine: r.docLine, ref: r.ref,
589
+ reason: `符号锚未命中:候选文件内无 \`${r.symbol}\` 的定义行(function/const/let/var/class/def 形态)——符号锚不随行号漂移,失效即符号拼错/归属错/已删改名`,
590
+ fix: { fixable: false, reason: '符号锚失效非重锚可修:核对符号拼写或文件归属(改名请同步文档符号)' },
591
+ })
592
+ continue
593
+ }
525
594
  // 多候选宽容:逐候选跑层1+层2,任一全过即通过
526
- const tokens = keywordAssert
595
+ const tokens = (keywordAssert && !r.kwSkip)
527
596
  ? extractExpectedTokensFromLine(mdLines[r.docLine - 1] || '')
528
597
  : []
529
598
  const candidateFails = []
@@ -548,7 +617,7 @@ export function runDocsCheck(opts) {
548
617
  const repoPrefix = r.repo ? `跨仓 repo://${r.repo} → ` : ''
549
618
  invalid.push({
550
619
  doc: docRel, docLine: r.docLine, ref: r.ref,
551
- reason: repoPrefix + (candidateFails.length > 1 ? `多候选全失败 → ${candidateFails.join(' | ')}` : candidateFails[0]),
620
+ reason: repoPrefix + (candidateFails.length > 1 ? `多候选全失败 → ${candidateFails.join(' | ')}` : candidateFails[0]) + (candidateFails.some(f => f.includes('关键词缺失')) ? '(跨文件引用/论述语境的纯位置锚:行号后加 ? 跳过关键词断言,层1 行界仍校验——勿删行号)' : ''),
552
621
  // 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
553
622
  suggest: suggestLines(candidates, tokens, linesCache),
554
623
  // 修复分类(--fix 判定依据):全量候选 token 命中打分——唯一命中或选优严格领先可自动重锚
@@ -558,7 +627,7 @@ export function runDocsCheck(opts) {
558
627
  }
559
628
  }
560
629
 
561
- return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped, skippedExempt, skippedFuzzy }
630
+ return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped, skippedExempt, skippedFuzzy, symbolChecked }
562
631
  }
563
632
 
564
633
  /**
@@ -0,0 +1,221 @@
1
+ /**
2
+ * friction-tally.js — 摩擦信号计数数据层(friction-signal-hint Phase 1)
3
+ *
4
+ * 定位:gate 失败回滚 / verify 实测失败 / 审查打回三类摩擦事件全部流经 CLI 自身
5
+ * 状态机,但此前没有任何计数与消费——是否值得 postmortem 全凭 agent 自觉判断。
6
+ * 本模块用确定性代码做结构化计数(立场照抄 teamai-cli 摩擦触发器:顺利会话零
7
+ * 打扰、每次收尾最多提示一次、可配置关闭),收尾时计数非零才由调用方输出一行
8
+ * advisory,引导按 现象/根因/护栏/证据 补 postmortem。
9
+ *
10
+ * 落点红线(D-002/R-04):计数文件只落 .runtime 树(平台同步排除区)——quick
11
+ * 会话落 quick-sessions/<sessionId>/friction-tally.json(session 目录收尾整体删除,
12
+ * 天然二次保险清零),真实变更落 <runtimeRoot>/friction-tally-<changeName>.json
13
+ * (归档时由 pruneArchivedChangeRuntime 一并清理)。**永不落 changes/ 目录**——
14
+ * 变更目录上平台同步,计数是本机行为数据,污染他机即事故。路径解析统一走
15
+ * shared.js 的 resolveRuntimeRoot/resolveQuickSessionsDir(比 verify-lint-tally
16
+ * 的 specBase 直拼更对齐平台模式,Grill CC-09)。
17
+ *
18
+ * 隐私红线(D-005):落盘字段只含 count/lastAt/at/type/detail——type ∈ 三值枚举、
19
+ * detail ∈ 预定义标签集(gate 来源/测试类型),永不接触提示词与对话原文。
20
+ *
21
+ * 静默降级立场(同 verify-lint-tally「计数器不许反向阻断」):读写全程 try/catch,
22
+ * 任何异常(只读目录/损坏 JSON/非法路径)吞掉返回 null/空 hint——埋点都在 gate
23
+ * 回滚、verify 失败这些已出错路径上,计数自身再抛异常只会火上浇油。本模块自身
24
+ * 不打印任何输出,提示何时打印由调用方决定。
25
+ */
26
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
27
+ import { dirname, join } from 'path'
28
+ import { writeAtomicSync } from './fs-atomic.js'
29
+ import { resolveRuntimeRoot, resolveQuickSessionsDir } from './run/shared.js'
30
+
31
+ /** 摩擦事件类型枚举(D-004):审查打回走专属 review_rejected,不与 gate_rollback 重复计 */
32
+ const FRICTION_TYPES = new Set(['gate_rollback', 'verify_run_failed', 'review_rejected'])
33
+
34
+ /** history 截尾上限(同 verify-lint-tally 的 20 条口径——台账防病态体量,不承载完整审计) */
35
+ const FRICTION_HISTORY_CAP = 20
36
+
37
+ /** quick 会话 id 形态:quick-<8 位 hex>(stage.js 建 session 目录的同一命名约定) */
38
+ const QUICK_SESSION_ID_RE = /^quick-[0-9a-f]{8}$/i
39
+
40
+ /** 提示文案的类型→中文标签映射(数组序即输出枚举序,固定 gate 回滚→验证失败→审查打回) */
41
+ const FRICTION_LABELS = [
42
+ ['gate_rollback', 'gate 回滚'],
43
+ ['verify_run_failed', '验证失败'],
44
+ ['review_rejected', '审查打回'],
45
+ ]
46
+
47
+ /**
48
+ * specBase 模块内部推导(D-006 CC-04):gates.js 的 rollbackCompletionAndReturn
49
+ * 等埋点签名里没有 specBase,调用方只传 cwd/platformOpts。推导序与 complete.js:125
50
+ * 收尾落盘同序(specRoot(平台) > specDriftAnchor(worktree 漂移锚定主仓) >
51
+ * cwd/.sillyspec(本地)),否则 quick 会话计到 worktree 副本、收尾从主仓读不到。
52
+ */
53
+ function deriveSpecBase(cwd, platformOpts) {
54
+ return platformOpts?.specRoot || platformOpts?.specDriftAnchor || join(cwd || process.cwd(), '.sillyspec')
55
+ }
56
+
57
+ /**
58
+ * changeName 安全校验:必须是单段文件名成分。含路径分隔符或「.」「..」会在拼
59
+ * friction-tally-<changeName>.json 时逃出 .runtime 树,违反 D-002 落点红线。
60
+ */
61
+ function isSafeChangeSegment(name) {
62
+ return typeof name === 'string' && name !== '' && name !== '.' && name !== '..' && !/[\\/]/.test(name)
63
+ }
64
+
65
+ /**
66
+ * 计数文件路径的单一路由出口(D-002/R-04:路由单一出口 + 单测断言两类路径都在
67
+ * .runtime 树内):quick-<8hex> 会话形态 → <sessionsDir>/<changeName>/friction-tally.json;
68
+ * 真实变更 → <runtimeRoot>/friction-tally-<changeName>.json。返回 null = 无有效
69
+ * 路径,调用方按 no-op 处理(空/非法 changeName 不记不消)。
70
+ */
71
+ function frictionTallyPath({ cwd, platformOpts, changeName }) {
72
+ if (!isSafeChangeSegment(changeName)) return null
73
+ const specBase = deriveSpecBase(cwd, platformOpts)
74
+ if (QUICK_SESSION_ID_RE.test(changeName)) {
75
+ return join(resolveQuickSessionsDir(platformOpts, specBase), changeName, 'friction-tally.json')
76
+ }
77
+ return join(resolveRuntimeRoot(platformOpts, specBase), `friction-tally-${changeName}.json`)
78
+ }
79
+
80
+ /** YAML 布尔值宽松解析:仅显式 false(含引号形态)为关,其余值一律默认开(R-05) */
81
+ function parseEnabledValue(raw) {
82
+ const v = String(raw || '').trim().replace(/^['"]|['"]$/g, '')
83
+ return v.toLowerCase() !== 'false'
84
+ }
85
+
86
+ /**
87
+ * 读 local.yaml 的 friction_hint.enabled(R-05:读失败=默认开,与「未配置=默认
88
+ * true」走同一条兜底路径——配置读取异常不该把提示误判为关)。
89
+ * 不引 yaml 依赖,行/正则解析(先例:extractTestCommand/extractLintCommand 同
90
+ * 风格);兼容嵌套(friction_hint:\n enabled: false)与 flat
91
+ * (friction_hint.enabled: false)两种写法;行尾先归一(坑
92
+ * verify-modules-crlf-blanket-fallback:Windows CRLF 残留致行式正则整体失配)。
93
+ * @param {string} specBase - spec 根(local.yaml 所在目录)
94
+ * @returns {boolean} false 仅当显式配了 false;缺键/文件缺失/解析异常/其它值 → true
95
+ */
96
+ function readFrictionHintEnabled(specBase) {
97
+ try {
98
+ const yamlPath = join(specBase, 'local.yaml')
99
+ if (!existsSync(yamlPath)) return true
100
+ const text = String(readFileSync(yamlPath, 'utf8')).replace(/\r\n?/g, '\n')
101
+ let inSection = false
102
+ let sectionIndent = -1
103
+ for (const line of text.split('\n')) {
104
+ // flat 形态优先:键名带点,不与嵌套段头冲突
105
+ const flat = line.match(/^\s*friction_hint\.enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
106
+ if (flat) return parseEnabledValue(flat[1])
107
+ const section = line.match(/^(\s*)friction_hint\s*:\s*(?:#.*)?$/)
108
+ if (section) {
109
+ inSection = true
110
+ sectionIndent = section[1].length
111
+ continue
112
+ }
113
+ if (!inSection) continue
114
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
115
+ // 缩进回落到段头层级(或更浅)= friction_hint 段结束,之后的 enabled 不属于它
116
+ if (/^\s*/.exec(line)[0].length <= sectionIndent) {
117
+ inSection = false
118
+ continue
119
+ }
120
+ const kv = line.match(/^\s*enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
121
+ if (kv) return parseEnabledValue(kv[1])
122
+ }
123
+ return true
124
+ } catch {
125
+ return true
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 读旧计数并归一成 { events, history } 骨架:缺失/损坏 JSON/字段脏型按零计重新
131
+ * 累计(同 recordVerifyLintTally 立场——旁路计数器对脏数据从零重来,不修复不报错)。
132
+ */
133
+ function readFrictionTally(tallyPath) {
134
+ const tally = { events: {}, history: [] }
135
+ try {
136
+ const raw = JSON.parse(readFileSync(tallyPath, 'utf8'))
137
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
138
+ if (raw.events && typeof raw.events === 'object' && !Array.isArray(raw.events)) tally.events = raw.events
139
+ if (Array.isArray(raw.history)) tally.history = raw.history
140
+ }
141
+ } catch { /* 首记/文件损坏 → 重置重新累计 */ }
142
+ return tally
143
+ }
144
+
145
+ /**
146
+ * 纯渲染(单测用):counts(type→count 平面映射)→ 一行提示文案;空/全零 → null。
147
+ * 只枚举非零段(三类不必同时出现);措辞条件式(「若其中有」)防 agent 为消除
148
+ * 提示而灌水记录(R-03 三重防线之一)。
149
+ */
150
+ export function renderFrictionHintLine(counts) {
151
+ const segments = []
152
+ for (const [type, label] of FRICTION_LABELS) {
153
+ const n = Number(counts ? counts[type] : null)
154
+ if (Number.isFinite(n) && n > 0) segments.push(`${label} ${n} 次`)
155
+ }
156
+ if (segments.length === 0) return null
157
+ return `🩹 本次会话累计摩擦信号:${segments.join('、')}——若其中有值得沉淀的坑,建议按 现象/根因/护栏/证据 补一条 postmortem(QUICKLOG 条目或正文核对);护栏结论经人工确认后归入 knowledge/known-issues.md`
158
+ }
159
+
160
+ /**
161
+ * 记一次摩擦事件。全静默降级:非法类型/无有效路径/enabled=false/读写异常一律
162
+ * 返回 null,绝不抛(埋点位于已出错路径,见文件头「静默降级立场」)。写走
163
+ * writeAtomicSync(计数文件会被收尾进程并发读,半截 JSON 会让 consume 误判全零)。
164
+ * @param {object} p
165
+ * @param {string} [p.cwd] 调用方 cwd(specBase 兜底推导用)
166
+ * @param {object} [p.platformOpts] 平台选项(runtimeRoot/specRoot/specDriftAnchor)
167
+ * @param {string} p.changeName 变更名或 quick 会话 id(空/非法段 → no-op)
168
+ * @param {string} p.type 摩擦类型,必须 ∈ FRICTION_TYPES
169
+ * @param {string} [p.detail] 预定义来源标签(如 verify-test/quick-audit),非字符串落 null
170
+ * @returns {{ type: string, count: number } | null} 落盘成功返回新计数;null = 未落盘
171
+ */
172
+ export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
173
+ try {
174
+ if (!FRICTION_TYPES.has(type)) return null
175
+ const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
176
+ if (!tallyPath) return null
177
+ // enabled=false 直通:record/consume 双 no-op,.runtime 零写入(D-003 一键全关)
178
+ if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return null
179
+ const tally = readFrictionTally(tallyPath)
180
+ const at = new Date().toISOString()
181
+ const prev = Number(tally.events[type] && tally.events[type].count)
182
+ const count = (Number.isFinite(prev) ? prev : 0) + 1
183
+ tally.events[type] = { count, lastAt: at }
184
+ tally.history = [
185
+ ...tally.history,
186
+ { at, type, detail: typeof detail === 'string' && detail ? detail : null },
187
+ ].slice(-FRICTION_HISTORY_CAP)
188
+ mkdirSync(dirname(tallyPath), { recursive: true })
189
+ writeAtomicSync(tallyPath, JSON.stringify(tally, null, 2))
190
+ return { type, count }
191
+ } catch {
192
+ return null
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 读计数并消费(D-003 清零语义):非零 → { hint: 一行提示, counts } 且删除计数
198
+ * 文件(提示后清零,防同一批摩擦在每个收尾点反复提示);全零/缺文件/读失败/
199
+ * enabled=false → { hint: null, counts: {} }。counts 只含 count>0 的枚举类型
200
+ * (未知类型/脏数据不进提示)。删文件失败不影响 hint 返回——残留计数下次收尾
201
+ * 会再消费一次,advisory 容忍重复,不因删失败丢提示。
202
+ * @returns {{ hint: string | null, counts: Record<string, number> }}
203
+ */
204
+ export function consumeFrictionHint({ cwd, platformOpts, changeName }) {
205
+ try {
206
+ const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
207
+ if (!tallyPath || !existsSync(tallyPath)) return { hint: null, counts: {} }
208
+ if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return { hint: null, counts: {} }
209
+ const tally = readFrictionTally(tallyPath)
210
+ const counts = {}
211
+ for (const type of FRICTION_TYPES) {
212
+ const n = Number(tally.events[type] && tally.events[type].count)
213
+ if (Number.isFinite(n) && n > 0) counts[type] = n
214
+ }
215
+ if (Object.keys(counts).length === 0) return { hint: null, counts }
216
+ try { rmSync(tallyPath, { force: true }) } catch { /* 删失败不吞提示 */ }
217
+ return { hint: renderFrictionHintLine(counts), counts }
218
+ } catch {
219
+ return { hint: null, counts: {} }
220
+ }
221
+ }