sillyspec 3.28.5 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.28.5",
3
+ "version": "3.28.6",
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,14 @@ 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
+ },
168
176
  ],
169
177
  };
170
178
 
@@ -294,6 +302,11 @@ test_strategy: full
294
302
  decisions:
295
303
  behind_threshold: 10
296
304
 
305
+ # ── 摩擦提示开关(quick/verify 收尾的摩擦信号一行 advisory:gate 回滚/验证失败/审查打回计数)──
306
+ # 默认 true 开启;false 一键全关(计数与提示双停,.runtime 零写入)
307
+ friction_hint:
308
+ enabled: true
309
+
297
310
  # ── 预存失败豁免清单(变更前就失败的测试行;务必定期复核,防误豁免真实失败)──
298
311
  known_failures:
299
312
  - 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
  /** 三级兜底终点域(仍不中归此;域文件由本模块自管) */
@@ -116,7 +119,10 @@ export function parseDecisions(changeDir) {
116
119
  // ①标题式(模板正统):`## D-001@v2 标题`,正文行 `- 字段:值`
117
120
  // ②扁平列表式(agent 实际产出):`- D-001@v2:标题` 或 `- D-001@v2 | 状态:implemented | 模块域:x`
118
121
  // ——行内 | 分段即字段;后续缩进子项 ` - 字段:值` 同样进字段
119
- const h = line.match(/^##\s+(D-\d+)(?:@v(\d+))?\s*(.*)$/)
122
+ // 标题级放宽 ^#{2,4}(坑 decision-heading-h3-colon-variant,2026-09-11 驾驭第十二批,
123
+ // 本仓 2026-08-08-concurrent-write-preflight 实证:### D-001@v1: 标题 形态 8 条 accepted
124
+ // 决策未入知识库)——agent 落盘的标题级漂移(##/###/####)与标题前导冒号均容收
125
+ const h = line.match(/^#{2,4}\s+(D-\d+)(?:@v(\d+))?\s*[::]?\s*(.*)$/)
120
126
  if (h) {
121
127
  flush()
122
128
  const version = h[2] ? parseInt(h[2], 10) : 1
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);
@@ -280,6 +280,8 @@ export class ChangeRegistry {
280
280
  console.warn('⚠️ renameChange: 新旧名称相同,跳过');
281
281
  return;
282
282
  }
283
+ // 注:新名日期前缀门禁只在 CLI 边界强制(index.js change-rename 入口)——renameChange 是
284
+ // 库函数,测试/平台工具合法用任意名(2026-09-11 决策)。
283
285
  const db = this.pm._ensureDB(cwd);
284
286
  // 检查旧名是否存在
285
287
  const existing = db.transaction(() => {
package/src/progress.js CHANGED
@@ -992,6 +992,9 @@ export class ProgressManager {
992
992
  }
993
993
  // quick 会话 id(quick-<uuid8>,见 run.js QUICK_SID_RE)只作 progress 的跨进程 session key,
994
994
  // 进度存 SQL 不需要实体 change 目录——跳过避免 changes/quick-<uuid>/ 空目录残留。
995
+ // 注:日期前缀门禁(assertDatedChangeName)只在 CLI 边界强制(run --change / change-rename,
996
+ // 见 run/command.js + index.js)——initChange 是库函数,测试/平台工具合法用任意名建 fixture,
997
+ // 不在此设门(2026-09-11 决策,避免 ~30 处测试 fixture 非日期名被拦)。
995
998
  if (!/^quick-[0-9a-f]{8}$/.test(changeName)) {
996
999
  this._ensureChangeDir(cwd, changeName);
997
1000
  }
@@ -23,7 +23,7 @@ import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveSpecDir, countAncestorSpecDirs, ancestorSpecDirs, resolveAncestorCeiling, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, resolveQuickSessionsDir, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, isTempResidueSpecRoot, PLATFORM_MANAGED_FILENAME, warnSelfRefPointerOnce } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, ancestorSpecDirs, resolveAncestorCeiling, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, assertDatedChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, resolveQuickSessionsDir, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, isTempResidueSpecRoot, PLATFORM_MANAGED_FILENAME, warnSelfRefPointerOnce } from './shared.js'
27
27
  import { resolveQuickLinkedChanges } from './quick-audit.js'
28
28
  import { outputStep, collectStageWaitHistory } from './prompt.js'
29
29
  import { completeStep, skipStep, waitStep, continueStep, synthesizeStepOutput } from './complete.js'
@@ -1046,6 +1046,22 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1046
1046
  process.exit(2) // 用法/环境错(目标变更不在当前库)→ exit 2
1047
1047
  }
1048
1048
  }
1049
+ // ── 变更名日期前缀门禁(brainstorm step6 规则 CLI 化,2026-09-11 实证 friction-signal-hint)──
1050
+ // 格式 YYYY-MM-DD-<简短描述> 此前只是 prompt 约束,agent 自拟名/重命名丢前缀时 CLI 照单
1051
+ // 全收静默物化。只拦「净新建」:DB 无行(!progress 已证)+ changes/(含 archive/)无目录;
1052
+ // 已物化存量(含归档无前缀旧名 auto-flow-optimization 等)不追诉,照常自愈初始化。
1053
+ // quick 阶段豁免:其 changeName 是会话 key(quick-<8hex>),非变更名。
1054
+ if (changeName && stageName !== 'quick' && !/^quick-[0-9a-f]{8}$/.test(changeName)
1055
+ && !existsSync(join(specBase, 'changes', changeName))
1056
+ && !existsSync(join(specBase, 'changes', 'archive', changeName))) {
1057
+ try {
1058
+ assertDatedChangeName(changeName)
1059
+ } catch (e) {
1060
+ console.error(`❌ ${e.message}`)
1061
+ console.error(` 重试:sillyspec run ${stageName} --change <YYYY-MM-DD-简短描述>`)
1062
+ process.exit(2) // 用法错(新变更名格式非法)→ exit 2
1063
+ }
1064
+ }
1049
1065
  // 如果指定了变更名或有变更目录,自动初始化变更的 progress
1050
1066
  const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
1051
1067
  if (autoChange) {
@@ -35,6 +35,7 @@ import { validateQuickResult, allocateQuicklogEntry, appendQuicklogEntryWithId,
35
35
  import { getRule } from '../stage-contract-spec.js'
36
36
  import { archiveDestDirName } from '../stage-contract.js'
37
37
  import { collectNumstatByPath } from '../scope-audit.js'
38
+ import { recordFrictionEvent, consumeFrictionHint } from '../friction-tally.js'
38
39
 
39
40
  /**
40
41
  * 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
@@ -170,6 +171,13 @@ export function pruneArchivedChangeRuntime(runtimeRoot, changeName) {
170
171
  if (existsSync(pathspec)) gone(pathspec)
171
172
  } catch {}
172
173
 
174
+ // friction tally(friction-signal-hint FR-05):摩擦计数随变更终态一并回收——真实变更的
175
+ // friction-tally-<change>.json 落 runtimeRoot,变更归档/删除后无读者,残留即孤儿文件。
176
+ try {
177
+ const fp = join(runtimeRoot, `friction-tally-${changeName}.json`)
178
+ if (existsSync(fp)) gone(fp)
179
+ } catch {}
180
+
173
181
  try {
174
182
  const execDir = join(runtimeRoot, 'execute-runs')
175
183
  if (existsSync(execDir)) {
@@ -1056,6 +1064,9 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1056
1064
  steps[currentIdx].status = 'pending'
1057
1065
  steps[currentIdx].completedAt = null
1058
1066
  if (outputText) steps[currentIdx].output = null
1067
+ // 摩擦计数(friction-signal-hint task-04):quick 审计被拦也是摩擦信号——exit 前记
1068
+ // 一笔(changeName=quick 会话 id,tally 落 session 目录,成功收尾 consume 后随目录清理)。
1069
+ recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-audit' })
1059
1070
  process.exit(1)
1060
1071
  }
1061
1072
  // P0-2(2026-09-02 跨 agent 工单):触及 src/test 的 quick --done 内置 test+lint
@@ -1076,6 +1087,9 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1076
1087
  steps[currentIdx].status = 'pending'
1077
1088
  steps[currentIdx].completedAt = null
1078
1089
  if (outputText) steps[currentIdx].output = null
1090
+ // 摩擦计数(friction-signal-hint task-04):test/lint 实测门禁拦下同记摩擦——exit 前
1091
+ // 落 session tally,与 quick-audit 同款静默降级。
1092
+ recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-test-lint' })
1079
1093
  process.exit(1)
1080
1094
  }
1081
1095
  progress.lastQuickReview = review
@@ -1223,6 +1237,10 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1223
1237
  }
1224
1238
  if (review?.docsCheckHint && review.docsCheckHint.invalid > 0) {
1225
1239
  auditNotes.push(`📎 文档引用失效:${review.docsCheckHint.invalid}/${review.docsCheckHint.total} 处 file:line 失效(sillyspec docs check 可复现)`)
1240
+ // 逐条指名(2026-09-11 同 quick-audit 侧:门禁输出直接带文件:行号,封顶 5 保持注记紧凑)
1241
+ for (const i of (review.docsCheckHint.invalidRefs || []).slice(0, 5)) {
1242
+ auditNotes.push(` ❌ [${i.doc}:${i.docLine}] ${i.ref} → ${i.reason}`)
1243
+ }
1226
1244
  // 行号漂移自动重锚(2026-09-08 用户反馈②:活文档 file:line 硬编码随任何插入失效,
1227
1245
  // 每回手跑 docs check --fix 是机械活):autoReanchorDocRefs = --fix 主链路编程化封装
1228
1246
  // (fixable 唯一/优选命中才改 + 定点替换 + 同口径回执),只作用于本次改动的文档。
@@ -1266,6 +1284,53 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1266
1284
  softFiles,
1267
1285
  })
1268
1286
  console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
1287
+ // 范围快照/patch 审计级落盘(quick-359a48f1):quicklog/patches/<qlId>.json+.patch——按
1288
+ // ql-ID 对齐 QUICKLOG 条目(平台按条目抓取),json 冗余 sessionId 供 guard 清理后记录态
1289
+ // 反查(computeQuickAudit → findQuickPatchRecord 同链)。patch 为 --done 时点全量冻结
1290
+ // (HEAD 未提交窗口,untracked 自拼 hunk)。fail-soft:失败只提示,不阻断收尾。
1291
+ try {
1292
+ const { computeChangeScopeAudit, collectNumstatByPath, buildFrozenPatch } = await import('../scope-audit.js')
1293
+ // changeName == sessionId(handleQuickStageCompletion 作用域内,§4.6 注释同款约定)
1294
+ const snapResult = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch: true })
1295
+ let quickRows = snapResult && snapResult.mode === 'quick' && Array.isArray(snapResult.rows)
1296
+ ? snapResult.rows.filter(r => r && r.attribution !== 'undeclared')
1297
+ : null
1298
+ let frozenPatch = snapResult ? snapResult.frozenPatch : undefined
1299
+ // 窗口空兜底(baseline 会话常态):--files 声明文件是会话启动时预存脏(audit baseline
1300
+ // 剔除后窗口空,quickRows 空),但声明即归属——改用声明文件集构造 rows + patch(行数按
1301
+ // HEAD 未提交窗口采集,同口径)。
1302
+ if ((!quickRows || quickRows.length === 0) && Array.isArray(guard?.allowedFiles) && guard.allowedFiles.length > 0) {
1303
+ const sessionRoot = dirname(specBase)
1304
+ const declaredPosix = guard.allowedFiles.map(f => String(f).replace(/\\/g, '/')).filter(Boolean)
1305
+ const stats = collectNumstatByPath(sessionRoot, declaredPosix, { baseRef: 'HEAD' })
1306
+ quickRows = declaredPosix.filter(f => stats.has(f) || existsSync(join(sessionRoot, f)))
1307
+ .map(f => {
1308
+ const st = stats.get(f) || { additions: null, deletions: null, kind: existsSync(join(sessionRoot, f)) ? 'modified' : 'deleted' }
1309
+ return { path: f, declared: true, additions: st.additions, deletions: st.deletions, kind: st.kind, attribution: 'declared' }
1310
+ })
1311
+ frozenPatch = buildFrozenPatch(sessionRoot, quickRows.map(r => r.path), { baseRef: 'HEAD' })
1312
+ }
1313
+ if (quickRows && quickRows.length > 0) {
1314
+ const patchesDir = join(specBase, 'quicklog', 'patches')
1315
+ mkdirSync(patchesDir, { recursive: true })
1316
+ const { ...snapRest } = snapResult || {}
1317
+ delete snapRest.frozenPatch
1318
+ writeFileSync(join(patchesDir, `${qlId}.json`), JSON.stringify({
1319
+ ...snapRest,
1320
+ rows: quickRows,
1321
+ totals: { files: quickRows.length, additions: quickRows.reduce((n, r) => n + (Number.isFinite(r.additions) ? r.additions : 0), 0), deletions: quickRows.reduce((n, r) => n + (Number.isFinite(r.deletions) ? r.deletions : 0), 0) },
1322
+ qlId,
1323
+ sessionId: changeName,
1324
+ savedAt: new Date().toISOString(),
1325
+ }, null, 2) + '\n')
1326
+ if (typeof frozenPatch === 'string' && frozenPatch) {
1327
+ writeFileSync(join(patchesDir, `${qlId}.patch`), frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n')
1328
+ }
1329
+ console.log(`📦 范围快照已落 quicklog/patches/${qlId}.json${typeof frozenPatch === 'string' && frozenPatch ? ' + .patch(--done 时点冻结)' : ''}`)
1330
+ }
1331
+ } catch (e) {
1332
+ console.warn(` ⚠️ quick 范围快照落盘失败(不阻断收尾):${e && e.message ? String(e.message).split('\n')[0] : e}`)
1333
+ }
1269
1334
  // 刷新 DB title:从 step3「需求:」提取(agent 可改 title 的途径),覆盖启动时的兜底快照。
1270
1335
  // 与 QUICKLOG 标题刷新(flipEntryInContent 内 extractTitleFromResult)同源,保持 DB↔QUICKLOG 一致。
1271
1336
  try {
@@ -1291,6 +1356,14 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1291
1356
  console.warn(`⚠️ 关联变更轻量归档失败(不阻断 quick 完成): ${e.message}`)
1292
1357
  }
1293
1358
 
1359
+ // 摩擦信号消费(friction-signal-hint task-04):必须在 session 目录清理**前**——清理后
1360
+ // tally 随目录删除,晚于此就读不到。QUICKLOG 完成打印之后 consume,提示里的 postmortem
1361
+ // 建议有落点可循;全零/读失败零输出,摩擦提示失败不影响收尾。
1362
+ try {
1363
+ const fr = consumeFrictionHint({ cwd, changeName, platformOpts })
1364
+ if (fr && fr.hint) console.log(`\n${fr.hint}`)
1365
+ } catch { /* 摩擦提示失败不影响收尾 */ }
1366
+
1294
1367
  // 清理 session 目录(rmSync/unlinkSync 容忍不存在)。路径与写入对齐(Q4 resolveQuickSessionsDir)。
1295
1368
  try {
1296
1369
  if (changeName) {
@@ -1851,7 +1924,7 @@ export async function handleScanStageCompleted({ stageName, currentIdx, cwd, pro
1851
1924
  // untracked 新文件 wc-l 记全 + 行、binary 显 BIN)。纯展示列(advisory,D-006):采集失败/行数
1852
1925
  // 不可得 → 括注省略不出伪数据;不改 auditQuickCompletion 判定、runQuickTestLintGate 与任何门禁。
1853
1926
  // 放文件尾(函数声明提升)而不内联进 handleQuickStageCompletion:docs/platform-interface-map
1854
- // 行号锚在上方(complete-handlers.js:1688 handleScanStageCompleted),内联大块会把 doc-ref 锚
1927
+ // 行号锚在上方(complete-handlers.js:1695 handleScanStageCompleted),内联大块会把 doc-ref 锚
1855
1928
  // 推出关键词窗口。
1856
1929
  //
1857
1930
  // 消费分工(调用方 quick 收尾):
@@ -666,6 +666,17 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
666
666
  // verify 完成出一行漂移确认。advisory fail-soft(D-006)——helper 内部全兜,绝不阻断完成。
667
667
  await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
668
668
 
669
+ // 摩擦信号消费(friction-signal-hint task-03):verify 收尾读一次 friction tally,非零打
670
+ // 一行 advisory 引导补 postmortem(consume 内部提示后清零);全零/读失败零输出——
671
+ // 摩擦提示失败不影响收尾。
672
+ if (stageName === 'verify') {
673
+ try {
674
+ const { consumeFrictionHint } = await import('../friction-tally.js')
675
+ const r = consumeFrictionHint({ cwd, changeName, platformOpts })
676
+ if (r && r.hint) console.log(`\n${r.hint}`)
677
+ } catch { /* 摩擦提示失败不影响收尾 */ }
678
+ }
679
+
669
680
  if (stageName === 'execute') {
670
681
  // execute run summary:展示真实可得的结构化信息
671
682
  try {
@@ -804,15 +815,21 @@ function scopeAuditPathSet(result) {
804
815
  return set
805
816
  }
806
817
 
807
- /** execute 完成路径:全表 + 快照落盘(快照写失败只提示,verify 对比按无快照降级) */
818
+ /** execute 完成路径:全表 + 审计级快照/patch 落盘(quick-359a48f1:changes/<变更名>/ 随归档
819
+ * 入库;patch 为收尾时点全量冻结——--file 真·当时内容比对的数据源。写失败只提示,verify
820
+ * 对比按无快照降级) */
808
821
  async function printExecuteScopeAudit({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit, renderScopeAuditTable }) {
809
- const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts })
822
+ const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch: true })
810
823
  console.log(`\n${renderScopeAuditTable(result, { maxRows: 60 })}`)
811
824
  try {
812
- const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
813
- const snapPath = join(runtimeRoot, `scope-audit-${changeName}.json`)
814
- mkdirSync(runtimeRoot, { recursive: true })
815
- writeFileSync(snapPath, JSON.stringify({ ...result, savedAt: new Date().toISOString() }, null, 2) + '\n')
825
+ const changeDir = join(specBase, 'changes', changeName)
826
+ mkdirSync(changeDir, { recursive: true })
827
+ const { frozenPatch, ...snap } = result
828
+ writeFileSync(join(changeDir, 'scope-audit.json'), JSON.stringify({ ...snap, savedAt: new Date().toISOString() }, null, 2) + '\n')
829
+ if (typeof frozenPatch === 'string') {
830
+ writeFileSync(join(changeDir, 'scope-audit.patch'), frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n')
831
+ }
832
+ console.log(` 📦 范围快照已落变更目录(scope-audit.json${typeof frozenPatch === 'string' ? ' + scope-audit.patch(收尾时点冻结)' : ''})`)
816
833
  } catch (e) {
817
834
  console.warn(` ⚠️ 范围对账快照写入失败(不阻断,verify 漂移对比将按无快照降级):${e && e.message ? String(e.message).split('\n')[0] : e}`)
818
835
  }
@@ -829,9 +846,14 @@ async function printVerifyScopeDrift({ cwd, changeName, specBase, platformOpts,
829
846
  return
830
847
  }
831
848
  let snapshot = null
849
+ // 快照读取链(quick-359a48f1):变更目录(审计级,execute --done 落)→ .runtime(存量兼容)
832
850
  try {
833
- snapshot = JSON.parse(readFileSync(join(resolveRuntimeRoot(platformOpts, specBase), `scope-audit-${changeName}.json`), 'utf8'))
834
- } catch { /* 快照缺失/损坏 → 跳过对比只打当前 totals */ }
851
+ snapshot = JSON.parse(readFileSync(join(specBase, 'changes', changeName, 'scope-audit.json'), 'utf8'))
852
+ } catch {
853
+ try {
854
+ snapshot = JSON.parse(readFileSync(join(resolveRuntimeRoot(platformOpts, specBase), `scope-audit-${changeName}.json`), 'utf8'))
855
+ } catch { /* 快照缺失/损坏 → 跳过对比只打当前 totals */ }
856
+ }
835
857
  if (!snapshot || snapshot.ok !== true) {
836
858
  console.log(` ${scopeLine}(无 execute 时点快照可对比——完整表跑 sillyspec scope-audit --change ${changeName})`)
837
859
  return
@@ -1474,6 +1496,15 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1474
1496
  // task-04:wait 解除完成路径与 completeStep 完成分支同源注入(Grill G-2:本路径不漏,
1475
1497
  // 否则 execute 快照缺失 → verify 假报「无快照可对比」)。
1476
1498
  await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
1499
+ // 摩擦信号消费(friction-signal-hint task-03):与 completeStep 完成分支同款(Grill CC-03:
1500
+ // 漏此则 --continue 收尾丢提示丢清零)。全零/读失败零输出,摩擦提示失败不影响收尾。
1501
+ if (stageName === 'verify') {
1502
+ try {
1503
+ const { consumeFrictionHint } = await import('../friction-tally.js')
1504
+ const r = consumeFrictionHint({ cwd, changeName, platformOpts })
1505
+ if (r && r.hint) console.log(`\n${r.hint}`)
1506
+ } catch { /* 摩擦提示失败不影响收尾 */ }
1507
+ }
1477
1508
  // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
1478
1509
  const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
1479
1510
  if (nextStageHint) {