sillyspec 3.28.5 → 3.28.7

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.
package/meta.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name_zh": "worktree 元数据",
3
+ "changeName": "2026-09-11-cross-change-decision-guard",
4
+ "branch": "sillyspec/2026-09-11-cross-change-decision-guard",
5
+ "baseBranch": "main",
6
+ "baseHash": "42aa9927f71b78c903709fadd681d71719855d66",
7
+ "worktreePath": "C:\\Users\\qinyi\\IdeaProjects\\sillyspec\\.sillyspec\\.runtime\\worktrees\\2026-09-11-cross-change-decision-guard",
8
+ "mode": "worktree",
9
+ "createdAt": "2026-09-11T06:50:20.659Z",
10
+ "provisioning": true
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.28.5",
3
+ "version": "3.28.7",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -0,0 +1,4 @@
1
+ a
2
+ b
3
+ c
4
+ d
@@ -165,6 +165,22 @@ export const LOCAL_YAML_SCHEMA = {
165
165
  { path: 'decisions.behind_threshold', type: 'integer', optional: true, status: 'live', readers: ['readDecisionRulesConfig (src/docs-check.js)'], desc: '决策 behind 复核阈值(锚定模块源码在「最近确认」commit 后的前进数),缺省 10;超阈报「决策待复核」提示。', example: '10' },
166
166
  ],
167
167
  },
168
+ {
169
+ id: 'friction_hint',
170
+ title: '摩擦提示开关',
171
+ note: 'quick/verify 收尾的摩擦信号计数提示配置(friction-signal-hint)。读键按「存在则读、不存在用缺省」容错:读失败与未配置同兜底为默认开。',
172
+ keys: [
173
+ { path: 'friction_hint.enabled', type: 'boolean', optional: true, status: 'live', readers: ['readFrictionHintEnabled (src/friction-tally.js)'], desc: '摩擦提示开关,默认 true;设 false 关闭摩擦提示(gate 回滚/验证失败/审查打回的收尾 advisory 与计数落盘全停,.runtime 零写入)。', example: 'true' },
174
+ ],
175
+ },
176
+ {
177
+ id: 'semantic_guard',
178
+ title: '语义守卫开关',
179
+ note: 'quick 进场注入与 --done 断言 WARNING 的跨变更决策守卫配置(cross-change-decision-guard)。读键按「存在则读、不存在用缺省」容错:读失败与未配置同兜底为默认开(fail-open,advisory 误开无害)。',
180
+ keys: [
181
+ { path: 'semantic_guard.enabled', type: 'boolean', optional: true, status: 'live', readers: ['readSemanticGuardEnabled (src/semantic-guard.js)'], desc: '语义守卫总开关,默认 true;设 false 时 quick 进场注入与 --done 断言 WARNING 全停零开销。', example: 'true' },
182
+ ],
183
+ },
168
184
  ],
169
185
  };
170
186
 
@@ -294,6 +310,16 @@ test_strategy: full
294
310
  decisions:
295
311
  behind_threshold: 10
296
312
 
313
+ # ── 摩擦提示开关(quick/verify 收尾的摩擦信号一行 advisory:gate 回滚/验证失败/审查打回计数)──
314
+ # 默认 true 开启;false 一键全关(计数与提示双停,.runtime 零写入)
315
+ friction_hint:
316
+ enabled: true
317
+
318
+ # ── 语义守卫开关(quick 进场注入 + --done 断言 WARNING 的跨变更决策守卫)──
319
+ # 默认 true 开启;false 一键全停(进场注入与断言 WARNING 双停,零开销)
320
+ semantic_guard:
321
+ enabled: true
322
+
297
323
  # ── 预存失败豁免清单(变更前就失败的测试行;务必定期复核,防误豁免真实失败)──
298
324
  known_failures:
299
325
  - tests/some-old-stable-failure
@@ -25,7 +25,10 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
25
25
  import { basename, dirname, join } from 'node:path'
26
26
 
27
27
  /** FR-02:有实现影响的五类 type(scope 与未知 type 的 confirmed/accepted 不入选) */
28
- const IMPLEMENTED_TYPES = new Set(['architecture', 'compatibility', 'boundary', 'definition', 'process'])
28
+ // feasibility 补录(2026-09-11 驾驭第十二批,本仓归档 2026-08-08-concurrent-write-preflight 实证:
29
+ // 8 条 feasibility+accepted 决策因五类集漏选从未入知识库)——可行性裁决是设计决策(技术选型/
30
+ // 可行 vs 不可行),与五类同格;排除集本意只针对 scope(范围裁决不入库)。
31
+ const IMPLEMENTED_TYPES = new Set(['architecture', 'compatibility', 'boundary', 'definition', 'process', 'feasibility', 'consistency'])
29
32
  /** FR-02:implemented 入选的 status(superseded 等其余状态不入选) */
30
33
  const IMPLEMENTED_STATUSES = new Set(['confirmed', 'accepted'])
31
34
  /** 三级兜底终点域(仍不中归此;域文件由本模块自管) */
@@ -75,6 +78,8 @@ function applyField(entry, label, value) {
75
78
  case 'answer': case '答案': entry.answer = value; break
76
79
  case '锚点': case 'anchor': entry.anchor = value; break
77
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
78
83
  case '否决理由': case 'reject_reason': case 'rejectreason': entry.rejectReason = value; break
79
84
  case '复潮条件': case 'revisit_when': case 'revisitwhen': entry.revisitWhen = value; break
80
85
  case 'supersedes': entry.supersedes = value; break
@@ -90,7 +95,7 @@ function applyField(entry, label, value) {
90
95
  * @returns {{ entries: Array<{
91
96
  * id: string, number: string, version: number, title: string,
92
97
  * type?: string, status?: string, question?: string, answer?: string,
93
- * anchor?: string, domains?: string[], rejectReason?: string, revisitWhen?: string,
98
+ * anchor?: string, domains?: string[], files?: string[], rejectReason?: string, revisitWhen?: string,
94
99
  * supersedes?: string, impacts?: string, normalizedRequirement?: string,
95
100
  * selected: 'implemented'|'rejected'|null, raw: string }>, missing: boolean }}
96
101
  * missing=true = decisions.md 不存在;selected = FR-02 入选裁决,null 表示解析保留但不入选。
@@ -116,7 +121,10 @@ export function parseDecisions(changeDir) {
116
121
  // ①标题式(模板正统):`## D-001@v2 标题`,正文行 `- 字段:值`
117
122
  // ②扁平列表式(agent 实际产出):`- D-001@v2:标题` 或 `- D-001@v2 | 状态:implemented | 模块域:x`
118
123
  // ——行内 | 分段即字段;后续缩进子项 ` - 字段:值` 同样进字段
119
- const h = line.match(/^##\s+(D-\d+)(?:@v(\d+))?\s*(.*)$/)
124
+ // 标题级放宽 ^#{2,4}(坑 decision-heading-h3-colon-variant,2026-09-11 驾驭第十二批,
125
+ // 本仓 2026-08-08-concurrent-write-preflight 实证:### D-001@v1: 标题 形态 8 条 accepted
126
+ // 决策未入知识库)——agent 落盘的标题级漂移(##/###/####)与标题前导冒号均容收
127
+ const h = line.match(/^#{2,4}\s+(D-\d+)(?:@v(\d+))?\s*[::]?\s*(.*)$/)
120
128
  if (h) {
121
129
  flush()
122
130
  const version = h[2] ? parseInt(h[2], 10) : 1
@@ -159,7 +167,7 @@ export function parseDecisions(changeDir) {
159
167
  }
160
168
 
161
169
  /** applyField 认识的字段标签白名单(行内 | 分段与缩进子项只按白名单进字段,散文误伤为零) */
162
- 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
163
171
 
164
172
  // ---------------------------------------------------------------------------
165
173
  // 域三级兜底(FR-03)
@@ -287,6 +295,8 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
287
295
  lines.push(`状态:${entry.selected}`)
288
296
  if (changeName) lines.push(`变更:${oneLine(changeName)}`)
289
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(', ')}`)
290
300
  lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
291
301
  lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
292
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
@@ -28,13 +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。
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
42
  /** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八)
40
43
  * + .sillyspec/changes/ + .sillyspec/knowledge/(P1-1,noai-ir-roadmap §4:最活跃、最易产生
@@ -127,6 +130,7 @@ export function collectDocRefs(md) {
127
130
  file: m[2],
128
131
  start: parseInt(m[3], 10),
129
132
  end: m[4] !== undefined ? parseInt(m[4], 10) : parseInt(m[3], 10),
133
+ kwSkip: m[5] === '?', // 纯位置锚(file.js:123?):层1 照校、层2 跳过
130
134
  docLine: line,
131
135
  })
132
136
  }
@@ -312,7 +316,7 @@ export function collectInvalidDocRefs(projectRoot, docEntries) {
312
316
  invalid.push({ doc: name, ref: ref.ref, reason: '文件不存在' })
313
317
  continue
314
318
  }
315
- const tokens = extractExpectedTokensFromLine(docLines[ref.docLine - 1] || '')
319
+ const tokens = ref.kwSkip ? [] : extractExpectedTokensFromLine(docLines[ref.docLine - 1] || '')
316
320
  const infos = candidates.map(abs => ({ lines: readLines(abs, linesCache) }))
317
321
  const ok = infos.some(({ lines }) => {
318
322
  if (!lines || lines.length === 0) return false
@@ -588,7 +592,7 @@ export function runDocsCheck(opts) {
588
592
  continue
589
593
  }
590
594
  // 多候选宽容:逐候选跑层1+层2,任一全过即通过
591
- const tokens = keywordAssert
595
+ const tokens = (keywordAssert && !r.kwSkip)
592
596
  ? extractExpectedTokensFromLine(mdLines[r.docLine - 1] || '')
593
597
  : []
594
598
  const candidateFails = []
@@ -613,7 +617,7 @@ export function runDocsCheck(opts) {
613
617
  const repoPrefix = r.repo ? `跨仓 repo://${r.repo} → ` : ''
614
618
  invalid.push({
615
619
  doc: docRel, docLine: r.docLine, ref: r.ref,
616
- 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 行界仍校验——勿删行号)' : ''),
617
621
  // 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
618
622
  suggest: suggestLines(candidates, tokens, linesCache),
619
623
  // 修复分类(--fix 判定依据):全量候选 token 命中打分——唯一命中或选优严格领先可自动重锚
@@ -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
+ }
package/src/index.js CHANGED
@@ -198,7 +198,7 @@ async function main() {
198
198
 
199
199
  // E22:重路径统一加载(轻路径 --version/help 已早退,未付此税)。
200
200
  const { ProgressManager, resolvePlatformSpecDir, resolvePlatformOpts } = await import('./progress.js');
201
- const { didYouMean, assertSafeChangeName, resolveSpecDir, detectWorktreeSpecDrift } = await import('./run/shared.js');
201
+ const { didYouMean, assertSafeChangeName, assertDatedChangeName, resolveSpecDir, detectWorktreeSpecDrift } = await import('./run/shared.js');
202
202
 
203
203
  // 解析全局选项
204
204
  let json = false;
@@ -3524,9 +3524,12 @@ checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";
3524
3524
  process.exit(1);
3525
3525
  }
3526
3526
  // F6 路径穿越消毒:renameChange 会 mv changes/<old> → changes/<new>,名含 ../ 会逃出 changes/。
3527
+ // 日期前缀门禁(brainstorm step6 规则 CLI 化):改名产生的新名必须 YYYY-MM-DD-<描述>
3528
+ // (2026-09-11 实证:friction-signal-hint 重命名丢前缀被静默物化)。
3527
3529
  try {
3528
3530
  assertSafeChangeName(oldName, '旧变更名');
3529
3531
  assertSafeChangeName(newName, '新变更名');
3532
+ assertDatedChangeName(newName, '新变更名');
3530
3533
  } catch (e) {
3531
3534
  console.error(`❌ ${e.message}`);
3532
3535
  process.exit(2);
@@ -5,6 +5,8 @@
5
5
  * 路由行发现——路由行由 decision-distill 幂等写入,本引擎只消费不写)——命中路由行的
6
6
  * 决策文件解析为 decisionHits(rejected 优先),供 brainstorm Step2 防复潮注入(task-04,FR-05)。
7
7
  * decisions 库不存在时(INDEX 无 Decisions 段/文件不存在)一切行为与无 decisions 库时一致。
8
+ * 另导出 matchDecisionsByFiles(task-02,FR-02):决策条目按代码文件路径反查——条目
9
+ * 「文件:」字段精确命中 ∪「锚点:」路径形态 token 提取兜底(quick 语义护栏的文件键引擎)。
8
10
  */
9
11
 
10
12
  import { existsSync, readFileSync } from 'fs'
@@ -59,19 +61,57 @@ function isDecisionRoute(entry) {
59
61
  }
60
62
 
61
63
  const DECISION_HEADER_RE = /^##\s+(D-\d+@v\d+)\s*(.*)$/
62
- const DECISION_FIELD_RE = /^(状态|理由|否决理由|复潮条件)\s*[::]\s*(.*)$/
64
+ const DECISION_FIELD_RE = /^(状态|文件|理由|否决理由|复潮条件)\s*[::]\s*(.*)$/
65
+ // 「锚点:」单独标签精确匹配读入(仿 docs-check.js parseDecisionEntries 先例)——绝不并入
66
+ // DECISION_FIELD_RE:其 else-if 链末尾的 reason 回填(else if (!cur.reason))会把锚点值
67
+ // 误吞进 reason(Grill X-002 隐性回归)。
68
+ const DECISION_ANCHOR_RE = /^锚点\s*[::]\s*(.*)$/
69
+ // 变更:单独标签(变更名限定——D-xxx 是变更内局部序号,跨变更同号互不相干,去重键依赖它)
70
+ const DECISION_CHANGE_RE = /^变更\s*[::]\s*(.*)$/
71
+
72
+ /** 「文件:」值切分 + 逐项反斜杠归一 POSIX(与 decision-distill 的 entry.files 契约对齐:
73
+ * split /[,,、\s]+/ + replace(/\\/g,'/'))。 */
74
+ function splitDecisionFiles(value) {
75
+ return String(value || '')
76
+ .split(/[,,、\s]+/)
77
+ .map(s => s.replace(/\\/g, '/'))
78
+ .filter(Boolean)
79
+ }
80
+
81
+ // 锚点路径 token 提取:路径形态 token + 扩展名白名单(扩展名按长度降序排列——
82
+ // (js|…|jsx) 短前缀在前会把 foo.jsx 截断成 foo.js)。
83
+ const ANCHOR_FILE_TOKEN_RE = /[\w./-]+\.(?:mjs|cjs|jsx|tsx|js|ts|py|go|java|rs)/g
84
+
85
+ /**
86
+ * 锚点值 → 代码文件路径集(条目无「文件:」字段时的兜底命中源)。
87
+ * 真实先例:D-905 形态「锚点:src/quicklog.js:493」→ 提取 src/quicklog.js
88
+ * (token 字符类不含 : 故止于行号前,再防御性剥 :line/:line-line/:符号 后缀)。
89
+ * 「未记录」/无路径形态 token → []。
90
+ */
91
+ function anchorFilePaths(anchor) {
92
+ const a = String(anchor || '').trim().replace(/\\/g, '/')
93
+ if (!a || a === '未记录') return []
94
+ const out = new Set()
95
+ for (const token of a.match(ANCHOR_FILE_TOKEN_RE) || []) {
96
+ const p = token.replace(/:(?:\d+(?:-\d+)?|[A-Za-z_$][A-Za-z0-9_$]*)$/, '')
97
+ if (p) out.add(p)
98
+ }
99
+ return [...out]
100
+ }
63
101
 
64
102
  /**
65
103
  * 解析单个 decisions/<域>.md 的 D-xxx@vN 条目。
66
104
  * reason 语义:rejected 条目取「否决理由」(expects_from task-02 的 reject_reason),
67
105
  * 其余条目回退「理由」一句话;revisitWhen 仅 rejected 条目非空(复潮条件)。
106
+ * files =「文件:」字段切分归一(task-02,FR-02);anchor =「锚点:」行原值(单独标签读入,
107
+ * 不进 reason 回填链——Grill X-002)。
68
108
  * @param {string} filePath 决策文件绝对路径
69
109
  * @param {string} file INDEX 相对路径(decisions/<域>.md),原样进 decisionHits.file
70
- * @returns {{ file: string, id: string, title: string, status: string, reason: string, revisitWhen: string }[]}
110
+ * @returns {{ file: string, id: string, title: string, status: string, reason: string, revisitWhen: string, files: string[], anchor: string }[]}
71
111
  */
72
112
  function parseDecisionFile(filePath, file) {
73
113
  let content
74
- try { content = readFileSync(filePath, 'utf8') } catch { return [] }
114
+ try { content = readFileSync(filePath, 'utf-8') } catch { return [] }
75
115
  const hits = []
76
116
  let cur = null
77
117
  const flush = () => {
@@ -82,14 +122,22 @@ function parseDecisionFile(filePath, file) {
82
122
  const h = line.match(DECISION_HEADER_RE)
83
123
  if (h) {
84
124
  flush()
85
- cur = { file, id: h[1], title: h[2].trim(), status: '', reason: '', revisitWhen: '' }
125
+ cur = { file, id: h[1], title: h[2].trim(), status: '', reason: '', revisitWhen: '', files: [], anchor: '', change: '' }
86
126
  continue
87
127
  }
88
128
  if (!cur) continue
129
+ // 锚点:单独标签先于字段链匹配(不在 DECISION_FIELD_RE 内,见常量处注释)
130
+ const a = line.match(DECISION_ANCHOR_RE)
131
+ if (a) { cur.anchor = a[1].trim(); continue }
132
+ // 变更:单独标签读入(坑 decision-cross-change-id-shadow,2026-09-11 语义护栏 dogfood 首跑实证:
133
+ // 域文件跨变更同号条目常见(D-xxx 是变更内局部序号),去重键须含变更名否则先到条目遮蔽后来者)
134
+ const c = line.match(DECISION_CHANGE_RE)
135
+ if (c) { cur.change = c[1].trim(); continue }
89
136
  const f = line.match(DECISION_FIELD_RE)
90
137
  if (!f) continue
91
138
  const value = f[2].trim()
92
139
  if (f[1] === '状态') cur.status = value.toLowerCase()
140
+ else if (f[1] === '文件') cur.files = splitDecisionFiles(value)
93
141
  else if (f[1] === '否决理由') cur.reason = value
94
142
  else if (f[1] === '复潮条件') cur.revisitWhen = value
95
143
  else if (!cur.reason) cur.reason = value // 理由:仅作 implemented(或否决理由缺失)时的回填
@@ -115,16 +163,52 @@ export function parseDecisionEntries(indexDir, indexEntries = null) {
115
163
  if (!existsSync(filePath)) continue // 路由行失效(域文件已删)→ 跳过不阻断
116
164
  hits.push(...parseDecisionFile(filePath, route.file))
117
165
  }
118
- // 多条路由行指向同一域文件时按 file+id 去重
166
+ // 多条路由行指向同一域文件时按 file+id+变更 去重(完全重复折叠);键含变更名——
167
+ // 跨变更同号条目(D-xxx 是变更内局部序号,域文件内同 id 不同变更常见)不再被先到者
168
+ // 遮蔽(坑 decision-cross-change-id-shadow,2026-09-11 dogfood 实证:runtime.md 七个
169
+ // D-001@v1 分属七变更,旧键只放行首个——同号 rejected 决策在 {DECISION_HITS} 防复潮
170
+ // 注入同样被遮蔽)。无「变更:」行的 legacy 条目 change='' 与同号 legacy 条目互折叠
171
+ // (维持旧行为,不放大历史条目数)。
119
172
  const seen = new Set()
120
173
  return hits.filter(h => {
121
- const key = `${h.file}#${h.id}`
174
+ const key = `${h.file}#${h.id}#${h.change || ''}`
122
175
  if (seen.has(key)) return false
123
176
  seen.add(key)
124
177
  return true
125
178
  })
126
179
  }
127
180
 
181
+ /**
182
+ * 决策条目按代码文件路径反查(task-02,FR-02)——quick 语义护栏(task-03/05/06)的
183
+ * 文件键引擎。条目命中源 = 「文件:」字段(cur.files)∪「锚点:」路径形态 token 提取
184
+ * (anchorFilePaths 兜底);查询侧与条目侧路径统一 POSIX 归一。
185
+ * @param {string} indexDir knowledge 目录路径
186
+ * @param {string[]} files 查询代码文件路径列表
187
+ * @returns {{ [file: string]: Array<{ id: string, title: string, status: string, reason: string, file: string }> }}
188
+ * 键 = 归一后的查询路径(结果键 ⊆ 查询集);条目 file 字段沿 parseDecisionFile 既有
189
+ * 口径(INDEX 相对 decisions/<域>.md 路径);rejected 条目同样进结果(status 透传,
190
+ * 消费方按需过滤);无 decisions 库 / 路由失效 / 空查询 → {}。
191
+ */
192
+ export function matchDecisionsByFiles(indexDir, files) {
193
+ const queries = [...new Set(
194
+ (Array.isArray(files) ? files : [files])
195
+ .map(f => String(f || '').trim().replace(/\\/g, '/'))
196
+ .filter(Boolean)
197
+ )]
198
+ if (queries.length === 0) return {}
199
+ const querySet = new Set(queries)
200
+ const result = {}
201
+ for (const hit of parseDecisionEntries(indexDir)) {
202
+ const sources = new Set([...(hit.files || []), ...anchorFilePaths(hit.anchor)])
203
+ for (const src of sources) {
204
+ if (!querySet.has(src)) continue
205
+ if (!result[src]) result[src] = []
206
+ result[src].push({ id: hit.id, title: hit.title, status: hit.status, reason: hit.reason, file: hit.file })
207
+ }
208
+ }
209
+ return result
210
+ }
211
+
128
212
  function escapeRegex(s) {
129
213
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
130
214
  }
@@ -62,6 +62,32 @@ export class ChangeRegistry {
62
62
  } catch { return null; }
63
63
  }
64
64
 
65
+ /**
66
+ * 读某变更最近一次进度活动时刻(quick-done-autoarchive-misfire 缺陷②,D-002@v1 复潮
67
+ * 2026-09-11 实证:stage 阶段态区分不了「活跃在途」与「启动后弃单」——brainstorm
68
+ * in_progress + tasks.md 仅含他者 quick 行时「全勾」是空洞真值)。取该变更全部
69
+ * stages/steps completed_at 的最大值:活跃会话分钟级在推进(最近活动必新);僵尸的
70
+ * 最后一步完成时刻必然陈旧。只读不抛:无变更/无完成行/读失败返回 null(调用方按
71
+ * 无近期活动放行,僵尸逃生通道语义不变——与 getStageCompletedAt 同族容错)。
72
+ */
73
+ getLatestActivityAt(cwd, changeName) {
74
+ try {
75
+ const db = this.pm._ensureDB(cwd);
76
+ const sqlDb = db.getDb();
77
+ const ch = sqlDb.prepare(`SELECT id FROM changes WHERE name = ?`).get(changeName);
78
+ if (!ch) return null;
79
+ const row = sqlDb.prepare(
80
+ `SELECT MAX(ts) AS latest FROM (
81
+ SELECT MAX(completed_at) AS ts FROM stages WHERE change_id = ?
82
+ UNION ALL
83
+ SELECT MAX(st.completed_at) AS ts FROM steps st
84
+ JOIN stages s ON st.stage_id = s.id WHERE s.change_id = ?
85
+ )`
86
+ ).get(ch.id, ch.id);
87
+ return row && row.latest ? row.latest : null;
88
+ } catch { return null; }
89
+ }
90
+
65
91
  /**
66
92
  * 读某变更某阶段的 started_at(2026-09-10 用户反馈②:evidence mtime 窗口锚点从
67
93
  * execute completed_at 放宽到 started_at——证据合法产自 execute 或 verify 两窗口,
@@ -280,6 +306,8 @@ export class ChangeRegistry {
280
306
  console.warn('⚠️ renameChange: 新旧名称相同,跳过');
281
307
  return;
282
308
  }
309
+ // 注:新名日期前缀门禁只在 CLI 边界强制(index.js change-rename 入口)——renameChange 是
310
+ // 库函数,测试/平台工具合法用任意名(2026-09-11 决策)。
283
311
  const db = this.pm._ensureDB(cwd);
284
312
  // 检查旧名是否存在
285
313
  const existing = db.transaction(() => {