sillyspec 3.28.6 → 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.6",
3
+ "version": "3.28.7",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -173,6 +173,14 @@ export const LOCAL_YAML_SCHEMA = {
173
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
174
  ],
175
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
+ },
176
184
  ],
177
185
  };
178
186
 
@@ -307,6 +315,11 @@ decisions:
307
315
  friction_hint:
308
316
  enabled: true
309
317
 
318
+ # ── 语义守卫开关(quick 进场注入 + --done 断言 WARNING 的跨变更决策守卫)──
319
+ # 默认 true 开启;false 一键全停(进场注入与断言 WARNING 双停,零开销)
320
+ semantic_guard:
321
+ enabled: true
322
+
310
323
  # ── 预存失败豁免清单(变更前就失败的测试行;务必定期复核,防误豁免真实失败)──
311
324
  known_failures:
312
325
  - tests/some-old-stable-failure
@@ -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 +
@@ -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 两窗口,
package/src/progress.js CHANGED
@@ -908,6 +908,9 @@ export class ProgressManager {
908
908
  // verifyStartAt 基准只读(2026-09-08-ir-verify-facts):execute 行 completed_at;无行 null 走 R-05 fallback
909
909
  getStageCompletedAt(cwd, changeName, stage) { return this._changeRegistry.getStageCompletedAt(cwd, changeName, stage); }
910
910
 
911
+ // 最近进度活动时刻只读(D-002@v1 复潮):quick --done 自动归档闸的时近性信号
912
+ getLatestActivityAt(cwd, changeName) { return this._changeRegistry.getLatestActivityAt(cwd, changeName); }
913
+
911
914
  // evidence mtime 窗口锚点(2026-09-10 用户反馈②):execute 行 started_at——证据合法产自 execute
912
915
  // 或 verify 两窗口,锚「完成时刻」会把 execute 期间产的证据判旧(时序两难);无行 null 走 completed_at → R-05
913
916
  getStageStartedAt(cwd, changeName, stage) { return this._changeRegistry.getStageStartedAt(cwd, changeName, stage); }
@@ -262,7 +262,13 @@ export async function runReviewDispatch(opts = {}) {
262
262
  const probeFn = probe || (await import('./dispatch/probe.js')).probeSillyHub
263
263
  const pr = await probeFn({ client, worktreePath: null, cwd })
264
264
  if (!pr || pr.available !== true) {
265
- return { ok: false, reason: `平台通道不可用(${pr && pr.reason ? pr.reason : 'probe 异常'})`, guidance: renderFallbackGuidance(cwd) }
265
+ const why = pr && pr.reason ? pr.reason : 'probe 异常'
266
+ // token 失效给「重连再配对」出口(2026-09-11 用户实测:gateway/daemon 都活着,
267
+ // 死的只是凭据——一把 platform connect 就能救活平台通道,比直接降级更对症)。
268
+ const tokenHint = why === 'mcp-token-invalid'
269
+ ? `mcp token 已失效(吊销/过期)——重跑 \`sillyspec platform connect ${client && client._url ? client._url : '<平台url>'} --token <user级token>\` 重连(connect 会成对签发 read+dispatch 新 token 自动覆盖写 mcp 段),然后重试平台通道。\n若暂不重连,按通道优先序降级:\n`
270
+ : ''
271
+ return { ok: false, reason: `平台通道不可用(${why})`, guidance: tokenHint + renderFallbackGuidance(cwd) }
266
272
  }
267
273
  // reviewRunId:优先复用 marker(与 prompt/gate 同链,勿手算)
268
274
  let runId = reviewRunId
@@ -22,8 +22,9 @@
22
22
  * archiveChangeDirectory 的 6 处 process.exit 全 exit(1):5 个顶层 guard 直接终止;L1325 在 catch 内主动 exit
23
23
  * (非被外层吞);process.exit 不可被 try 捕获 → 搬迁行为完全等价。
24
24
  */
25
- import { basename, join, resolve, relative, isAbsolute } from 'node:path'
25
+ import { basename, dirname, join, resolve, relative, isAbsolute } from 'node:path'
26
26
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'
27
+ import { createHash } from 'node:crypto'
27
28
  import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
28
29
  import { gitQuiet } from '../git-helper.js'
29
30
  import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata, isQuicklogFileLineNoise, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, mergeQuickBoundaryFiles } from './shared.js'
@@ -1313,20 +1314,26 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1313
1314
  if (quickRows && quickRows.length > 0) {
1314
1315
  const patchesDir = join(specBase, 'quicklog', 'patches')
1315
1316
  mkdirSync(patchesDir, { recursive: true })
1316
- const { ...snapRest } = snapResult || {}
1317
- delete snapRest.frozenPatch
1318
- writeFileSync(join(patchesDir, `${qlId}.json`), JSON.stringify({
1317
+ const { frozenPatch: _fp, ...snapRest } = snapResult || {}
1318
+ const snapObj = {
1319
1319
  ...snapRest,
1320
1320
  rows: quickRows,
1321
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
1322
  qlId,
1323
1323
  sessionId: changeName,
1324
1324
  savedAt: new Date().toISOString(),
1325
- }, null, 2) + '\n')
1325
+ }
1326
+ snapObj.note = 'quick --done 时点冻结(本文件落盘时采集)'
1326
1327
  if (typeof frozenPatch === 'string' && frozenPatch) {
1327
- writeFileSync(join(patchesDir, `${qlId}.patch`), frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n')
1328
+ const patchText = frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n'
1329
+ writeFileSync(join(patchesDir, `${qlId}.patch`), patchText)
1330
+ snapObj.patchSha256 = createHash('sha256').update(patchText, 'utf8').digest('hex')
1331
+ snapObj.patchStatus = 'ok'
1332
+ } else if (frozenPatch === null) {
1333
+ snapObj.patchStatus = 'failed'
1328
1334
  }
1329
- console.log(`📦 范围快照已落 quicklog/patches/${qlId}.json${typeof frozenPatch === 'string' && frozenPatch ? ' + .patch(--done 时点冻结)' : ''}`)
1335
+ writeFileSync(join(patchesDir, `${qlId}.json`), JSON.stringify(snapObj, null, 2) + '\n')
1336
+ console.log(`📦 范围快照已落 quicklog/patches/${qlId}.json${snapObj.patchStatus === 'ok' ? ' + .patch(--done 时点冻结,sha256 已锚)' : snapObj.patchStatus === 'failed' ? '——patch 采集失败已留痕' : ''}`)
1330
1337
  }
1331
1338
  } catch (e) {
1332
1339
  console.warn(` ⚠️ quick 范围快照落盘失败(不阻断收尾):${e && e.message ? String(e.message).split('\n')[0] : e}`)
@@ -1465,6 +1472,10 @@ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, pla
1465
1472
  */
1466
1473
  const QUICK_CLOSE_ALLOWED_STAGES = new Set(['', 'scan', 'brainstorm'])
1467
1474
 
1475
+ /** 时近性闸窗口(缺陷②复潮实现):最近进度活动 60 分钟内的变更不自动归档——活跃会话
1476
+ * 步进间隔为分钟级(brainstorm 8 步通常 <1h),僵尸的最后活动以小时/天计。 */
1477
+ const QUICK_CLOSE_ACTIVITY_WINDOW_MS = 60 * 60 * 1000
1478
+
1468
1479
  /**
1469
1480
  * quick --done 完成后,自动关闭任务已全部完成的关联真实变更。
1470
1481
  * quick-<hex> sessionId 自身不在此处理(由调用方单独注销)。
@@ -1514,6 +1525,25 @@ export async function closeQuickLinkedChanges({ pm, cwd, specBase, linkedChanges
1514
1525
  })
1515
1526
  continue
1516
1527
  }
1528
+ // 时近性闸(quick-done-autoarchive-misfire 缺陷②,D-002@v1 复潮,2026-09-11
1529
+ // cross-change-decision-guard 实证):阶段态区分不了「活跃在途」与「启动后弃单」——
1530
+ // brainstorm in_progress + tasks.md 仅含他者 quick 行时「全勾」是空洞真值(完整流程
1531
+ // plan 前无自有任务行),关联 quick --done 勾掉 ql 行即触发在途变更被轻量归档。
1532
+ // 信号取时近性:最近一次 stage/step 完成时刻在窗口内 = 会话分钟级在推进(在途),
1533
+ // 不自动归档;僵尸的最后活动必然陈旧(或无完成步 → null),逃生通道语义不变
1534
+ //(既有「真·僵尸 in_progress→closed」行为保留)。pm 无此方法(旧 mock/旧进度库)
1535
+ // 按无近期活动放行——与 getChangeStage 缺失 skip 的 fail-safe 不同向:本闸误放行的
1536
+ // 最坏后果是回到缺陷②现状(有 D-002 防护兜底),误拦截则僵尸永不清——权衡取放行。
1537
+ if (typeof pm.getLatestActivityAt === 'function') {
1538
+ const latest = pm.getLatestActivityAt(cwd, changeName)
1539
+ if (latest && Date.now() - new Date(latest).getTime() < QUICK_CLOSE_ACTIVITY_WINDOW_MS) {
1540
+ skipped.push({
1541
+ name: changeName,
1542
+ reason: `变更 ${Math.round((Date.now() - new Date(latest).getTime()) / 60000)} 分钟内仍有进度活动(在途会话,非僵尸),不自动归档——确已弃单请走原流程收尾或窗口过后重试`,
1543
+ })
1544
+ continue
1545
+ }
1546
+ }
1517
1547
  if (!isChangeTasksComplete(specBase, changeName)) {
1518
1548
  skipped.push({ name: changeName, reason: 'tasks.md 未全勾选或不存在' })
1519
1549
  continue
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { join } from 'node:path'
20
20
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
21
+ import { createHash } from 'node:crypto'
21
22
  import { writeAtomicSync } from '../fs-atomic.js'
22
23
  import { gitQuiet } from '../git-helper.js'
23
24
  import { withFileLock } from '../quicklog.js'
@@ -817,28 +818,39 @@ function scopeAuditPathSet(result) {
817
818
 
818
819
  /** execute 完成路径:全表 + 审计级快照/patch 落盘(quick-359a48f1:changes/<变更名>/ 随归档
819
820
  * 入库;patch 为收尾时点全量冻结——--file 真·当时内容比对的数据源。写失败只提示,verify
820
- * 对比按无快照降级) */
821
+ * 对比按无快照降级。审查修复:freshActual 绕过 settled 快照捷径防陈旧回写(execute 重跑
822
+ * 场景 C-F01);落盘 note 换冻结措辞防「自述漂移」污染(C-F06);patchSha256/patchStatus
823
+ * 留防篡改锚与失败痕(A-F01/C-F07) */
821
824
  async function printExecuteScopeAudit({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit, renderScopeAuditTable }) {
822
- const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch: true })
825
+ const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch: true, freshActual: true })
823
826
  console.log(`\n${renderScopeAuditTable(result, { maxRows: 60 })}`)
824
827
  try {
825
- const changeDir = join(specBase, 'changes', changeName)
828
+ // 归档竞态(审查 C-F10):目录已被并行归档移动 → 写归档侧,不在活跃区重建假目录
829
+ const activeDir = join(specBase, 'changes', changeName)
830
+ const changeDir = existsSync(activeDir) ? activeDir : join(specBase, 'changes', 'archive', changeName)
826
831
  mkdirSync(changeDir, { recursive: true })
827
832
  const { frozenPatch, ...snap } = result
828
- writeFileSync(join(changeDir, 'scope-audit.json'), JSON.stringify({ ...snap, savedAt: new Date().toISOString() }, null, 2) + '\n')
833
+ snap.note = 'execute --done 时点冻结(本文件落盘时采集)'
829
834
  if (typeof frozenPatch === 'string') {
830
- writeFileSync(join(changeDir, 'scope-audit.patch'), frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n')
835
+ const patchText = frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n'
836
+ writeFileSync(join(changeDir, 'scope-audit.patch'), patchText)
837
+ snap.patchSha256 = createHash('sha256').update(patchText, 'utf8').digest('hex')
838
+ snap.patchStatus = 'ok'
839
+ } else if (frozenPatch === null) {
840
+ snap.patchStatus = 'failed'
831
841
  }
832
- console.log(` 📦 范围快照已落变更目录(scope-audit.json${typeof frozenPatch === 'string' ? ' + scope-audit.patch(收尾时点冻结)' : ''})`)
842
+ writeFileSync(join(changeDir, 'scope-audit.json'), JSON.stringify({ ...snap, savedAt: new Date().toISOString() }, null, 2) + '\n')
843
+ console.log(` 📦 范围快照已落变更目录(scope-audit.json${snap.patchStatus === 'ok' ? ' + scope-audit.patch(收尾时点冻结,sha256 已锚)' : snap.patchStatus === 'failed' ? '——patch 采集失败已留痕' : ''})`)
833
844
  } catch (e) {
834
845
  console.warn(` ⚠️ 范围对账快照写入失败(不阻断,verify 漂移对比将按无快照降级):${e && e.message ? String(e.message).split('\n')[0] : e}`)
835
846
  }
836
847
  }
837
848
 
838
849
  /** verify 完成路径:读 execute 时点快照对比文件集差集,一行结论(行数不比——execute 形态 A 锚
839
- * worktree meta、verify 形态 B 锚 merge-base,基点不同行数天然抖动,比行数必假阳) */
850
+ * worktree meta、verify 形态 B 锚 merge-base,基点不同行数天然抖动,比行数必假阳)。
851
+ * 审查 C-F01:current 侧 freshActual——settled 快照捷径会让本函数变成快照比快照恒「一致」 */
840
852
  async function printVerifyScopeDrift({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit }) {
841
- const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts })
853
+ const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, freshActual: true })
842
854
  const totals = result?.totals && typeof result.totals === 'object' ? result.totals : {}
843
855
  const scopeLine = `变更范围:${totals.files ?? (Array.isArray(result?.rows) ? result.rows.length : 0)} 文件 +${totals.additions ?? 0}/-${totals.deletions ?? 0}`
844
856
  if (result?.ok !== true) {
package/src/run/prompt.js CHANGED
@@ -22,7 +22,8 @@ import { basename, join } from 'node:path'
22
22
  import { existsSync, readFileSync, mkdirSync, readdirSync } from 'node:fs'
23
23
  import { writeAtomicSync } from '../fs-atomic.js'
24
24
  import { stageRegistry } from '../stages/index.js'
25
- import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE, QUICK_SID_RE } from './shared.js'
25
+ import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, parsePorcelainPath, WAIT_MARKER_RE, QUICK_SID_RE } from './shared.js'
26
+ import { renderSemanticGuardBlock, readSemanticGuardEnabled } from '../semantic-guard.js'
26
27
  import { renderStageContract } from '../stage-contract-spec.js'
27
28
  import { nowWallClock } from '../datetime.js'
28
29
  import { parseModuleMapSimple } from '../modules.js'
@@ -181,6 +182,44 @@ export function buildQuickContextDigest(specBase, projectName) {
181
182
  }
182
183
  }
183
184
 
185
+ /**
186
+ * quick step1 语义护栏进场注入拼装(task-05,FR-03,D-001@v1 模块四):开关 → 候选文件采集 →
187
+ * renderSemanticGuardBlock 反查。候选 = 会话声明文件(guard.allowedFiles,readQuickGuardField
188
+ * 同源读取:session guard 优先 / legacy 单文件回退)∪ git status --porcelain 脏文件
189
+ * (parsePorcelainPath 同款解析口径:引号剥离 / -> rename / 反斜杠归一),反斜杠归一去重封顶 20。
190
+ * 返回 ''(开关关 / 无候选 / 双零命中——prompt 与现状字节一致,零命中静默)或 advisory 段;
191
+ * 全链 fail-soft:异常返回单行降级说明,不阻断 quick 启动。导出供注入单测直测
192
+ * (test/semantic-guard-prompt-inject.test.mjs)。
193
+ */
194
+ export function buildQuickSemanticGuardInjection({ specBase, cwd, changeName } = {}) {
195
+ try {
196
+ // 开关首行(Grill X-012):false 直接零输出——不采候选文件、不跑反查(开关语义全停非半停)
197
+ if (!readSemanticGuardEnabled(specBase)) return ''
198
+ // 会话声明文件(--files 落 guard.allowedFiles;缺失 / 形态异常按空)
199
+ const declaredRaw = readQuickGuardField(changeName, specBase, 'allowedFiles')
200
+ const declared = Array.isArray(declaredRaw) ? declaredRaw : []
201
+ // git 脏文件:porcelain 解析同源 quick-audit(trim:false——首列前导空格是状态码,trim 会削掉
202
+ // 致 parsePorcelainPath 丢首字符;.sillyspec/ 运行时产物过滤同款——候选面是代码文件)
203
+ let dirty = []
204
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
205
+ if (!statusResult.error) {
206
+ dirty = (statusResult.value || '').split('\n').filter(Boolean)
207
+ .map(l => parsePorcelainPath(l))
208
+ .filter(Boolean)
209
+ .filter(f => !f.startsWith('.sillyspec/'))
210
+ }
211
+ // 去重(反斜杠归一后;renderSemanticGuardBlock 内侧亦有同款归一,此处为封顶前的口径)
212
+ // 封顶 20 —— 与 collectRecentForeignDelivery 内侧文件封顶同数
213
+ const candidateFiles = [...new Set(
214
+ [...declared, ...dirty].map(f => String(f || '').trim().replace(/\\/g, '/')).filter(Boolean)
215
+ )].slice(0, 20)
216
+ if (candidateFiles.length === 0) return ''
217
+ return renderSemanticGuardBlock({ specBase, cwd, candidateFiles, currentChange: changeName })
218
+ } catch (e) {
219
+ return `【语义护栏】注入失败(${e && e.message ? e.message : e})——跳过反查,不阻断本步骤;需要时自查 knowledge/INDEX.md 与 git log`
220
+ }
221
+ }
222
+
184
223
  // parseModuleMapSimple 复用 modules.js 的 canonical 实现(合并历史 copy-paste 副本,2026-08-07;
185
224
  // 无循环依赖:modules.js 仅 import fs/path/db.js,prompt.js → modules.js 单向)。
186
225
  /**
@@ -1061,6 +1100,19 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1061
1100
  }
1062
1101
  }
1063
1102
 
1103
+ // ── 语义护栏进场注入(task-05,FR-03,D-001@v1 模块四)──
1104
+ // 判定口径独立:与上方模块上下文注入同锚(quickFirstStep——quick step1「理解任务」),不嵌进
1105
+ // {QUICK_CONTEXT_DIGEST} 占位符守卫内——模板改版去占位符时护栏静默失效(plan 审查 N2)。
1106
+ // 非空追加到 step1 prompt 末尾;空串(开关关 / 零命中)prompt 与现状字节一致(零命中静默)。
1107
+ // 渲染层注入,src/stages/quick.js 模板零变更(design 明确不改清单)。全链 fail-soft 见 helper。
1108
+ if (quickFirstStep) {
1109
+ const sgSpecBase = resolvePromptSpecBase(platformOpts, cwd)
1110
+ const semanticGuardBlock = buildQuickSemanticGuardInjection({ specBase: sgSpecBase, cwd, changeName })
1111
+ if (semanticGuardBlock) {
1112
+ promptText = promptText + '\n\n' + semanticGuardBlock
1113
+ }
1114
+ }
1115
+
1064
1116
  // 平台模式 prompt 自检:确保没有裸相对输出路径
1065
1117
  // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
1066
1118
  if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { join } from 'node:path'
15
15
  import { parsePorcelainPath, safeGit } from './shared.js'
16
+ import { collectRecentForeignDelivery, detectAssertionRewrites, readSemanticGuardEnabled } from '../semantic-guard.js'
16
17
 
17
18
  /**
18
19
  * 打印 quick 完成审计结论(按 review.status 输出 SAFE/WARNING/BLOCKED)。
@@ -242,6 +243,32 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
242
243
 
243
244
  // ============ quick --done test+lint 硬门禁(2026-09-02 跨 agent 工单 P0-2)============
244
245
 
246
+ /**
247
+ * 语义护栏命中组装(change: 2026-09-11-cross-change-decision-guard,task-06,FR-04,
248
+ * D-001@v1):断言重写检测 ∩ 近因他者交付归因 → hits 清单,gate 内一行调用拼进返回对象。
249
+ * 单独抽出导出是为了可测性(runQuickTestLintGate 会真跑 test/lint 命令,检测组装层
250
+ * 独立测可避免测试里真跑 npm test——卡内可测性豁免)。
251
+ * - 步骤 0 总开关:readSemanticGuardEnabled false → 跳过检测,hits 恒空(fail-open 同 task-03)
252
+ * - 纯新增断言(detect 不命中)/ 无他者归因标记 / 非测试文件 → 落不到 hits
253
+ * - WARNING 级非阻断:本函数只组装数据,不参与 gate action/failed 判定
254
+ * @param {{ cwd?: string, specBase?: string, files?: string[], changeName?: string|null }} opts
255
+ * @returns {{ hits: Array<{ file: string, deliveredBy: string, sampleLines: string[] }> }}
256
+ */
257
+ export function buildSemanticGuardHits({ cwd, specBase, files, changeName } = {}) {
258
+ if (!readSemanticGuardEnabled(specBase)) return { hits: [] }
259
+ const rewrites = detectAssertionRewrites({ cwd, files: Array.isArray(files) ? files : [] })
260
+ if (Object.keys(rewrites).length === 0) return { hits: [] }
261
+ // 归因只查断言命中文件(省 git log 调用;collectRecentForeignDelivery 内部封顶 20 文件)
262
+ const delivery = collectRecentForeignDelivery({ cwd, files: Object.keys(rewrites), currentChange: changeName })
263
+ const hits = []
264
+ for (const file of Object.keys(rewrites)) {
265
+ // 无归因不点名:纯新增断言 / 无标记提交 / 交付者是本变更(currentChange 透传跳过)同落空
266
+ if (!delivery[file]) continue
267
+ hits.push({ file, deliveredBy: delivery[file], sampleLines: rewrites[file] })
268
+ }
269
+ return { hits }
270
+ }
271
+
245
272
  /**
246
273
  * quick --done 内置 test+lint 实测门禁:把 CLAUDE.md 规则 8「触及 src/test 的改动先
247
274
  * npm test + npm run lint」从 agent 自律下沉为 CLI 卡点。
@@ -265,8 +292,11 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
265
292
  * @param {string} opts.specBase - .sillyspec 目录(local.yaml 读取源,信任边界见 runVerifyTestCheck SEC-02 注释)
266
293
  * @param {string[]} [opts.changedFiles] - 审计口径的本轮变更文件(仓库根相对 POSIX 路径;空/null → 回退 declaredFiles)
267
294
  * @param {string[]} [opts.declaredFiles] - 会话声明边界(guard.allowedFiles;changedFiles 为空时的兜底判定源)
268
- * @param {string} [opts.changeName] - quick 会话名(evidence-auto 策略解析用)
269
- * @returns {Promise<{action:'pass'|'fail'|'skip', failed:string[], reason:string, test:object|null, lint:object|null}>}
295
+ * @param {string} [opts.changeName] - quick 会话名(evidence-auto 策略解析用 + 语义护栏归因 currentChange 透传)
296
+ * @returns {Promise<{action:'pass'|'fail'|'skip', failed:string[], reason:string, test:object|null, lint:object|null,
297
+ * semanticGuard?:{hits:Array<{file:string,deliveredBy:string,sampleLines:string[]}>}}>}
298
+ * semanticGuard 仅检测跑过(三条早退 skip 路径不跑检测、无字段)时挂载;WARNING 级
299
+ * 非阻断——action/failed 判定不受它影响(调用方 complete-handlers.js 只读 action/failed,零改动)。
270
300
  */
271
301
  export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], declaredFiles = [], changeName = null }) {
272
302
  if (process.env.SILLYSPEC_QUICK_TEST_GATE === 'skip') {
@@ -284,6 +314,13 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
284
314
  return { action: 'skip', failed: [], reason: `纯 doc/配置改动(${files.length} 个文件均未触及 src/test,规则 8 语义跳过 test+lint)`, test: null, lint: null }
285
315
  }
286
316
 
317
+ // ── 语义护栏检测(task-06,FR-04 断言重写 WARNING,D-001@v1 非阻断)──
318
+ // 三条早退(env skip / 无文件 / 纯 doc)已在上文 return,检测不跑——语义一致。
319
+ // 检测对主仓 cwd 执行而非 gateCwd 隔离快照(下方才建快照):检测对象是本会话工作树
320
+ // 未提交改动(git diff HEAD 须见它),快照语义是 test/lint 实测隔离——两者不同层
321
+ // (Grill X-001 附注)。specBase 同理用主仓的(快照副本 local.yaml 不参与开关判定)。
322
+ const semanticGuard = buildSemanticGuardHits({ cwd, specBase, files, changeName })
323
+
287
324
  const { runVerifyTestCheck, runVerifyLintCheck } = await import('../verify-postcheck.js')
288
325
 
289
326
  // ── 隔离快照执行(2026-09-10 驾驭小结第六批②,用户实证「lint 实测对账在主仓跑被并行
@@ -319,6 +356,8 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
319
356
  : `触及 src/test 共 ${codeFiles.length} 个文件,test+lint 实测通过(或未配置命令自动跳过)${snapshot ? '(隔离快照口径:并行会话脏文件不计入)' : ''}`,
320
357
  test,
321
358
  lint,
359
+ // 语义护栏 WARNING(task-06):只增可选字段,action/failed 判定与调用方读取面均不受影响
360
+ semanticGuard,
322
361
  }
323
362
  } finally {
324
363
  if (snapshot) { try { snapshot.cleanup() } catch { /* 清理失败不连坐门禁结论 */ } }
@@ -350,4 +389,18 @@ export function printQuickTestLintGate(gate) {
350
389
  } else {
351
390
  console.log(`\n✅ quick test+lint 门禁 — PASS(${gate.reason})`)
352
391
  }
392
+
393
+ // 语义护栏 WARNING(task-06 / FR-04,非阻断——D-001@v1:误报校准数据为零先 advisory,
394
+ // 复潮条件:误报率实证后可升 block):他者近因交付的测试文件既有断言行被改 → 点名
395
+ // 文件 + 交付变更 + 被改样例行(上游 detectAssertionRewrites 已封顶 5/文件,渲染侧
396
+ // 再 slice(0,5) 兜底)。hits 空 / 字段缺失(三条早退 skip 路径不跑检测)→ 零输出。
397
+ const sgHits = gate.semanticGuard && Array.isArray(gate.semanticGuard.hits) ? gate.semanticGuard.hits : []
398
+ if (sgHits.length > 0) {
399
+ console.warn(`\n⚠️ 语义护栏 — 断言重写 WARNING(非阻断):他者近因交付的测试文件既有断言被改`)
400
+ for (const h of sgHits) {
401
+ console.warn(` - ${h.file} ← ${h.deliveredBy} 交付`)
402
+ for (const s of (h.sampleLines || []).slice(0, 5)) console.warn(` 被改: ${String(s).trim()}`)
403
+ }
404
+ console.warn(` ➜ 若确需重写:重写理由写进 quicklog --solution(--done 四参数收尾);并复查 ${[...new Set(sgHits.map(h => h.deliveredBy))].join('、')} 的关联决策——勿以改断言重定义绿灯。`)
405
+ }
353
406
  }
@@ -19,6 +19,7 @@
19
19
  * 纯读:不落盘、不改 guard/进度库。Windows 路径 \\→/ 归一,ESM-only,零新依赖。
20
20
  */
21
21
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
+ import { createHash } from 'node:crypto'
22
23
  import { join, dirname } from 'node:path'
23
24
  import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs } from './run/shared.js'
24
25
  import { safeGit } from './git-helper.js'
@@ -41,7 +42,9 @@ function toPosix(p) {
41
42
  function extractRenameTarget(raw) {
42
43
  const brace = raw.match(/^(.*)\{([^{}]*)=>([^{}]*)\}(.*)$/)
43
44
  if (brace) {
44
- const merged = brace[1] + brace[3] + brace[4]
45
+ // git 花括号形态 `src/{old => new}.js`:组3 带前导对齐空格须剥(审查 C-F03——
46
+ // 不剥则拼出 `src/ new.js` 键错,改名文件落 untracked wc-l 档记成整文件新增)
47
+ const merged = brace[1] + brace[3].replace(/^ /, '') + brace[4]
45
48
  return merged.replace(/\/{2,}/g, '/')
46
49
  }
47
50
  const arrow = raw.indexOf(' => ')
@@ -262,6 +265,21 @@ function filterPatchForFiles(patchText, filesSet) {
262
265
  return out.join('\n')
263
266
  }
264
267
 
268
+ /**
269
+ /** patch 内容 sha256(A-F01 防篡改锚——写入方存 json.patchSha256,读取方校验) */
270
+ function sha256Text(text) {
271
+ return createHash('sha256').update(text, 'utf8').digest('hex')
272
+ }
273
+
274
+ /**
275
+ * 冻结 patch 完整性校验(A-F01):json 记录的 patchSha256 与 patch 实文比对。
276
+ * @returns {boolean|null} true=一致;false=不匹配(可能被篡改);null=未记录 hash(旧记录)
277
+ */
278
+ function verifyPatchIntegrity(recordedSha, patchText) {
279
+ if (!recordedSha || typeof recordedSha !== 'string') return null
280
+ return sha256Text(patchText) === recordedSha
281
+ }
282
+
265
283
  /**
266
284
  * 冻结 patch 生成(quick-359a48f1 审计级 / quick-90015473 归属口径修正):tracked 改动 =
267
285
  * `git diff --no-color <baseRef>` 后**按 files 过滤段**(与 rows 同归属口径,不含并行会话
@@ -438,8 +456,12 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collec
438
456
  }
439
457
  }
440
458
 
459
+ // 审查 C-F05:patch 文件集对齐落盘 rows(declared+soft)——undeclared 可能是并行会话的
460
+ // 改动,冻结进本会话审计 patch 违背 filterPatchForFiles 的归属契约(json rows 落盘时
461
+ // complete-handlers 滤 undeclared,patch 必须同口径)
462
+ const patchFiles = [...new Set([...attributed, ...softSet])]
441
463
  const frozenPatch = collectPatch
442
- ? buildFrozenPatch(sessionRoot, rowFiles, { baseRef: 'HEAD' })
464
+ ? buildFrozenPatch(sessionRoot, patchFiles, { baseRef: 'HEAD' })
443
465
  : undefined
444
466
 
445
467
  return {
@@ -462,7 +484,7 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collec
462
484
  * only;baseAnchor 缺失 → 行数按 HEAD 未提交窗口兜底(quick 模式同口径,不再恒 —);
463
485
  * 归档且实时窗口空 → execute 时点快照记录态(快照也缺 → 空表诚实说明)。
464
486
  */
465
- async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch }) {
487
+ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual }) {
466
488
  const sb = specBase || join(cwd, '.sillyspec')
467
489
  const runtimeRoot = resolveRuntimeRoot(platformOpts, sb)
468
490
  const changeDirInfo = resolveChangeDir(sb, changeName)
@@ -474,7 +496,30 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
474
496
  const hasWorktreeMeta = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'))
475
497
  const hasBranch = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
476
498
  const hasAuditTag = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec-audit/sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
477
- if (!hasWorktreeMeta && !hasBranch && !hasAuditTag) {
499
+ // 审查 C-F02:预执行判定必须排除归档(归档=流程走完,58/77 归档变更三信号全缺被误判
500
+ // 「未执行」实证——tag 只在分支被 review 引用时才打)+ 补四类证据:快照文件本身 /
501
+ // execute-runs change 戳(task-review readExecuteRunChangeStamp 同口径)/ apply-pathspec /
502
+ // execute-cleanup 回执。任一命中 = 进过 execute。
503
+ const hasSnapshotFile = changeDirInfo
504
+ ? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
505
+ : existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
506
+ const hasApplyPathspec = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
507
+ const hasCleanupReceipt = existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
508
+ let hasExecuteRunStamp = false
509
+ if (!hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt) {
510
+ try {
511
+ const runsDir = join(runtimeRoot, 'execute-runs')
512
+ if (existsSync(runsDir)) {
513
+ for (const e of readdirSync(runsDir)) {
514
+ try {
515
+ if (readFileSync(join(runsDir, e, 'change'), 'utf8').trim() === changeName) { hasExecuteRunStamp = true; break }
516
+ } catch { /* 无戳 run 跳过 */ }
517
+ }
518
+ }
519
+ } catch { /* 扫描失败视为无证据(fail-open 到预执行视图,note 有 hedge) */ }
520
+ }
521
+ const isArchived = !!(changeDirInfo && changeDirInfo.archived)
522
+ if (!isArchived && !hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt && !hasExecuteRunStamp) {
478
523
  const emptyBase = {
479
524
  mode: 'full-flow', ok: true, degradedReason: null, baseAnchor: null,
480
525
  totals: { files: 0, additions: 0, deletions: 0 }, rows: [],
@@ -495,8 +540,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
495
540
  totals: { files: preRows.length, additions: 0, deletions: 0 },
496
541
  rows: preRows,
497
542
  note: preRows.length > 0
498
- ? '变更尚未进入 execute(无 worktree/分支/审计 tag)——下表为 design 清单(全部待实现);工作区其他未提交改动归属各自会话/变更,不在本表(若确已执行过但三信号均被手工清理,此处会误判——git log 核实)'
499
- : '变更尚未进入 execute(无 worktree/分支/审计 tag)且 design 无可解析清单——暂无可对账内容',
543
+ ? '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)——下表为 design 清单(全部待实现);工作区其他未提交改动归属各自会话/变更,不在本表(若确已执行过但全部执行证据被清理,此处会误判——git log 核实)'
544
+ : '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)且 design 无可解析清单——暂无可对账内容',
500
545
  }
501
546
  }
502
547
 
@@ -558,7 +603,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
558
603
  // 快照即冻结记录(含行数)——已收尾一律先出快照;实时窗口(开放区间)只作快照缺失的兜底,
559
604
  // note 明示会漂。tag 封闭区间不可用:分支上仅 baseline checkpoint(并行 WIP 快照),本变更
560
605
  // 改动经工作树 apply 从未 commit 到分支。 ——
561
- const settled = changeDirInfo && (changeDirInfo.archived || actual.form === 'post-apply')
606
+ // freshActual(审查 C-F01):verify 漂移 current 侧走快照捷径会变成快照比快照恒「一致」
607
+ // (假阴性);execute --done 采集重跑也会陈旧回写——两处传 true 绕过,快照捷径只留给查询面
608
+ const settled = !freshActual && changeDirInfo && (changeDirInfo.archived || actual.form === 'post-apply')
562
609
  if (settled) {
563
610
  const snap = readScopeSnapshot(changeDirInfo.dir, runtimeRoot, changeName)
564
611
  if (snap && snap.rows.length > 0) {
@@ -571,7 +618,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
571
618
  const needsStats = rows.some(r => r && !Number.isFinite(r.additions) && r.kind !== 'binary')
572
619
  if (needsStats && actual && actual.ok && actual.baseAnchor) {
573
620
  const paths = rows.map(r => (r && r.path ? toPosix(r.path) : '')).filter(Boolean)
574
- const stats = collectNumstatByPath(cwd, paths, { baseRef: actual.baseAnchor })
621
+ // 审查 C-F04:补采执行根走共享 resolveDiffRoot(form=worktree 时必须对 worktree 跑,
622
+ // 与主链路 :629/:636 同源配对——直用 cwd 会基点×根错位)
623
+ const stats = collectNumstatByPath(resolveDiffRoot(sb, changeName, actual.form, cwd), paths, { baseRef: actual.baseAnchor })
575
624
  let recovered = 0
576
625
  rows = rows.map(r => {
577
626
  if (!r || !r.path || Number.isFinite(r.additions) || r.kind === 'binary') return r
@@ -591,6 +640,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
591
640
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
592
641
  ? snap.excluded
593
642
  : { foreignDeclared: [] },
643
+ patchSha256: snap.patchSha256 || null,
644
+ patchStatus: snap.patchStatus || null,
594
645
  note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
595
646
  }
596
647
  }
@@ -606,6 +657,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
606
657
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
607
658
  ? snap.excluded
608
659
  : { foreignDeclared: [] },
660
+ patchSha256: snap.patchSha256 || null,
661
+ patchStatus: snap.patchStatus || null,
609
662
  note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
610
663
  }
611
664
  }
@@ -715,7 +768,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
715
768
  * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
716
769
  * note?: string|null }>}
717
770
  */
718
- export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch } = {}) {
771
+ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual } = {}) {
719
772
  const empty = {
720
773
  mode: 'full-flow', ok: false, degradedReason: null, baseAnchor: null,
721
774
  totals: { files: 0, additions: 0, deletions: 0 },
@@ -746,6 +799,8 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
746
799
  ? rec.record.excluded
747
800
  : { foreignDeclared: [] },
748
801
  frozenPatchPath: rec.patchPath,
802
+ patchSha256: rec.record.patchSha256 || null,
803
+ patchStatus: rec.record.patchStatus || null,
749
804
  note: `quick 会话已收尾——记录态(quicklog/patches/${rec.qlId},${rec.record.savedAt ? String(rec.record.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘' : '--done 时点冻结'})`,
750
805
  }
751
806
  }
@@ -755,7 +810,7 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
755
810
  degradedReason: `quick 会话 ${changeName} 不存在(guard 已清理且 quicklog/patches/ 无记录——会话未产生改动或早于记录机制)`,
756
811
  }
757
812
  }
758
- return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch })
813
+ return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual })
759
814
  } catch (e) {
760
815
  // fail-soft 兜底(D-006 / 兼容策略):注入点只打一行提示,不阻断阶段完成
761
816
  return {
@@ -789,16 +844,36 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
789
844
  // —— 真·当时内容比对(quick-359a48f1):已收尾变更优先冻结 patch 切片 ——
790
845
  // ① quick 记录态自带 frozenPatchPath;② full-flow 变更目录 scope-audit.patch。
791
846
  // patch 冻结在收尾时点,不含后续演进——实时锚 diff 只作 patch 缺失的兜底(口径标注)。
792
- const frozenPatch = (result.frozenPatchPath
793
- && (() => { try { return readFileSync(result.frozenPatchPath, 'utf8') } catch { return null } })())
794
- || readFrozenPatch(resolveChangeDir(sb, changeName)?.dir || null)
795
- if (frozenPatch) {
847
+ // A-F01:读取时按 json 记录的 patchSha256 校验,不匹配显式告警(防低级篡改)。
848
+ const patchRecord = result.frozenPatchPath
849
+ ? { path: result.frozenPatchPath, sha: result.patchSha256 || null }
850
+ : (() => { const d = resolveChangeDir(sb, changeName); return d ? { path: join(d.dir, 'scope-audit.patch'), sha: null } : null })()
851
+ let recordedSha = patchRecord ? patchRecord.sha : null
852
+ if (patchRecord && !recordedSha && result.rows) {
853
+ // json 里未直接带回 hash(记录态捷径不透传)→ 读 json 原文取(快照/quick 记录都存该字段)
854
+ try {
855
+ const snapRaw = JSON.parse(readFileSync(result.frozenPatchPath
856
+ ? result.frozenPatchPath.replace(/\.patch$/, '.json')
857
+ : join(resolveChangeDir(sb, changeName).dir, 'scope-audit.json'), 'utf8'))
858
+ recordedSha = snapRaw.patchSha256 || null
859
+ } catch { /* json 不可读 → 无 hash 可校验(null=不校验) */ }
860
+ }
861
+ const frozenPatch = (patchRecord
862
+ && (() => { try { return readFileSync(patchRecord.path, 'utf8') } catch { return null } })())
863
+ if (frozenPatch !== null && frozenPatch !== undefined && patchRecord) {
864
+ const integrity = verifyPatchIntegrity(recordedSha, frozenPatch)
865
+ if (integrity === false) {
866
+ return { ok: false, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '⚠️ 冻结 patch 与快照记录的 patchSha256 不匹配——记录可能被篡改,审计前人工核验(git 历史比对)' }
867
+ }
796
868
  const sliced = slicePatchForFile(frozenPatch, norm)
797
869
  if (sliced) {
798
870
  return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch(收尾时点内容,无后续演进混入)', diff: sliced, note: null }
799
871
  }
800
872
  return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '该文件不在冻结 patch 内(窗口外文件)' }
801
873
  }
874
+ if (result.patchStatus === 'failed') {
875
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结记录', diff: null, note: '冻结 patch 当时采集失败(非无改动)——退实时锚比对' }
876
+ }
802
877
 
803
878
  // 锚点解析(语义锚 head-uncommitted-window → 实际 baseRef 'HEAD';无锚同兜底并标注)
804
879
  let baseRef = 'HEAD'
@@ -0,0 +1,207 @@
1
+ /**
2
+ * semantic-guard.js — quick 语义护栏聚合模块(change: 2026-09-11-cross-change-decision-guard,
3
+ * task-03,FR-03/FR-04,D-001@v1):近因他者交付归因 + 断言重写检测 + advisory 渲染 + 总开关。
4
+ *
5
+ * 被 run/prompt.js(task-05,进场注入)与 run/quick-audit.js(task-06,--done 断言 WARNING)
6
+ * 两端消费。纯模块纪律:不 console(渲染返回字符串,输出留给消费端)、不写盘、
7
+ * 不 import run/ 下模块(避免与 task-05/06 循环依赖)。
8
+ *
9
+ * git 交互一律走 git-helper 的 safeGit(数组参数不经 shell,Windows 路径安全);失败静默
10
+ * 降级空结果不抛(acceptance:git 不可用 → 全部降级空结果)。
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { safeGit } from './git-helper.js'
15
+ import { matchDecisionsByFiles } from './knowledge-match.js'
16
+
17
+ /** 路径反斜杠归一 POSIX(Windows 调用方传 \ 分隔时不丢匹配;与 matchDecisionsByFiles 双侧归一口径一致) */
18
+ function toPosix(p) {
19
+ return String(p || '').trim().replace(/\\/g, '/')
20
+ }
21
+
22
+ // ───────────────────────── 交付归因(FR-03) ─────────────────────────
23
+
24
+ /** 提交信息里的完整变更名标记:init 生成的 change 目录名形态(2026-09-11-cross-change-decision-guard) */
25
+ const CHANGE_NAME_RE = /\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*/g
26
+ /** quicklog 条目 ID 标记:ql-YYYYMMDD-NNN-xxxx(quick 会话提交信息的归因键) */
27
+ const QL_ID_RE = /ql-\d{8}-\d{3}-[a-z0-9]{4}/g
28
+
29
+ /** 单条提交信息 → 两类标记列表(按出现位置排序;两类正则无交集,ql-ID 的日期段无连字符) */
30
+ function extractChangeMarkers(subject) {
31
+ const found = []
32
+ for (const re of [CHANGE_NAME_RE, QL_ID_RE]) {
33
+ for (const m of subject.matchAll(re)) found.push({ token: m[0], at: m.index })
34
+ }
35
+ return found.sort((a, b) => a.at - b.at).map(f => f.token)
36
+ }
37
+
38
+ /**
39
+ * 近因他者交付归因:每文件查最近 days 天提交信息(--format=%s,仅 subject),解析变更名 /
40
+ * ql-ID 两类标记,取最新一条非 currentChange 标记 → { [file]: changeName }。
41
+ * git log 输出行为提交 subject,无 porcelain 首行前导空格语义(git-helper.js:88 的
42
+ * trim 坑不影响此处),safeGit 默认 trim 安全。
43
+ * @param {{ cwd?: string, files?: string[], currentChange?: string, days?: number }} opts
44
+ * @returns {{ [file]: string }} 键 = POSIX 归一路径;无标记 / 仅本变更标记 / git 失败 /
45
+ * 第 20 个之后的文件 → 不记
46
+ */
47
+ export function collectRecentForeignDelivery({ cwd, files, currentChange, days = 7 } = {}) {
48
+ const result = {}
49
+ if (!cwd || !Array.isArray(files)) return result
50
+ const list = files.map(toPosix).filter(Boolean).slice(0, 20)
51
+ for (const file of list) {
52
+ const r = safeGit(cwd, ['log', `--since=${days}.days`, '--format=%s', '--', file])
53
+ if (r.error || !r.value) continue
54
+ // git log 新→旧:逐提交找第一条非 currentChange 标记,命中即止(取最新他者标记)
55
+ for (const subject of r.value.split('\n')) {
56
+ const foreign = extractChangeMarkers(subject).find(m => m !== currentChange)
57
+ if (foreign) {
58
+ result[file] = foreign
59
+ break
60
+ }
61
+ }
62
+ }
63
+ return result
64
+ }
65
+
66
+ // ───────────────────────── 断言重写检测(FR-04) ─────────────────────────
67
+
68
+ /** 删除侧断言 token:expect( / assert / t.equal / toBe / toEqual / strictEqual / should. */
69
+ const ASSERTION_TOKEN_RE = /expect\(|assert|t\.equal|toBe|toEqual|strictEqual|should\./
70
+
71
+ /** 测试文件判定:test/ 前缀 或 *.test.* / *_test.* 命名(路径先 POSIX 归一) */
72
+ function isTestFilePath(p) {
73
+ return p.startsWith('test/') || /\.test\./.test(p) || /_test\./.test(p)
74
+ }
75
+
76
+ /**
77
+ * 断言重写检测:测试文件跑 git diff HEAD -U0(HEAD 口径非 index——quick step3 先 git add
78
+ * 暂存的时序下裸 diff 恒空转,坑 Grill X-001),hunk 删除侧(- 行)含断言 token → 命中;
79
+ * 纯新增 hunk(无 - 行)不算;样例行封顶 5/文件。
80
+ * @param {{ cwd?: string, files?: string[] }} opts
81
+ * @returns {{ [file]: string[] }} 键 = POSIX 归一测试文件路径,值 = 删除侧含 token 的原行
82
+ * (去 - 前缀);非测试文件不查;无命中 / git 失败 → 不记
83
+ */
84
+ export function detectAssertionRewrites({ cwd, files } = {}) {
85
+ const result = {}
86
+ if (!cwd || !Array.isArray(files)) return result
87
+ for (const raw of files) {
88
+ const file = toPosix(raw)
89
+ if (!file || !isTestFilePath(file)) continue
90
+ const r = safeGit(cwd, ['diff', 'HEAD', '-U0', '--', file])
91
+ if (r.error || !r.value) continue
92
+ const samples = []
93
+ let inHunk = false
94
+ let deletedLines = []
95
+ const flushHunk = () => {
96
+ if (!inHunk || deletedLines.length === 0) return // 纯新增 hunk(无 - 行)不算
97
+ for (const line of deletedLines) {
98
+ if (samples.length >= 5) break
99
+ if (ASSERTION_TOKEN_RE.test(line)) samples.push(line)
100
+ }
101
+ }
102
+ for (const line of r.value.split('\n')) {
103
+ if (line.startsWith('@@')) {
104
+ flushHunk()
105
+ inHunk = true
106
+ deletedLines = []
107
+ continue
108
+ }
109
+ if (!inHunk) continue // 文件头(diff --git / index / --- / +++)不进 hunk 统计
110
+ if (line.startsWith('-')) deletedLines.push(line.slice(1)) // + 行与 "\ No newline" 行跳过
111
+ }
112
+ flushHunk()
113
+ if (samples.length > 0) result[file] = samples
114
+ }
115
+ return result
116
+ }
117
+
118
+ // ───────────────────────── advisory 渲染(FR-03/FR-04 组合) ─────────────────────────
119
+
120
+ /**
121
+ * advisory 文本段渲染:决策命中(matchDecisionsByFiles,knowledge 目录 = specBase/knowledge,
122
+ * task-02 契约)+ 交付归因(collectRecentForeignDelivery 默认 7 天窗)组合成注入文本,
123
+ * 供 task-05 进场注入 / task-06 --done WARNING 拼接;两类均零命中 → ''(不留空段)。
124
+ * @param {{ specBase?: string, cwd?: string, candidateFiles?: string[], currentChange?: string }} opts
125
+ * @returns {string}
126
+ */
127
+ export function renderSemanticGuardBlock({ specBase, cwd, candidateFiles, currentChange } = {}) {
128
+ if (!Array.isArray(candidateFiles) || candidateFiles.length === 0) return ''
129
+ const files = [...new Set(candidateFiles.map(toPosix).filter(Boolean))]
130
+ if (files.length === 0) return ''
131
+ const decisions = specBase
132
+ ? matchDecisionsByFiles(join(specBase, 'knowledge'), files)
133
+ : {}
134
+ const delivery = cwd
135
+ ? collectRecentForeignDelivery({ cwd, files, currentChange })
136
+ : {}
137
+ const decisionFiles = files.filter(f => Array.isArray(decisions[f]) && decisions[f].length > 0)
138
+ const deliveryFiles = files.filter(f => delivery[f])
139
+ if (decisionFiles.length === 0 && deliveryFiles.length === 0) return ''
140
+ const lines = ['【语义护栏】改动文件触及既有决策 / 近期他者交付,动手前请对照:']
141
+ if (decisionFiles.length > 0) {
142
+ lines.push(`■ 决策库命中(${decisionFiles.length} 文件)`)
143
+ for (const f of decisionFiles) {
144
+ for (const hit of decisions[f]) {
145
+ const rejected = hit.status === 'rejected'
146
+ lines.push(` - ${f} → ${hit.id} ${hit.title}[${rejected ? 'rejected,已否决,勿复潮' : hit.status || 'unknown'}]`)
147
+ lines.push(` 理由:${hit.reason || '(未记录)'}`)
148
+ if (hit.file) lines.push(` 详见:knowledge/${hit.file}`)
149
+ }
150
+ }
151
+ }
152
+ if (deliveryFiles.length > 0) {
153
+ lines.push('■ 近 7 天他者交付归因(最后交付的非本变更标记)')
154
+ for (const f of deliveryFiles) lines.push(` - ${f} ← ${delivery[f]}`)
155
+ }
156
+ return lines.join('\n')
157
+ }
158
+
159
+ // ───────────────────────── 总开关(D-001@v1) ─────────────────────────
160
+
161
+ /** YAML 布尔宽松解析:仅显式 false(含引号形态)为关,其余值一律 true(fail-open,先例 friction-tally R-05) */
162
+ function parseEnabledValue(raw) {
163
+ const v = String(raw || '').trim().replace(/^['"]|['"]$/g, '')
164
+ return v.toLowerCase() !== 'false'
165
+ }
166
+
167
+ /**
168
+ * 读 specBase/local.yaml 的 semantic_guard.enabled 总开关(D-001@v1):默认 true;
169
+ * 缺键 / 文件缺失 / 解析异常 → true(fail-open——配置读取异常不该把护栏误判为关)。
170
+ * 轻量行式解析不引 yaml 依赖(先例 friction-tally readFrictionHintEnabled 同款形态),
171
+ * 兼容嵌套(semantic_guard:\n enabled: false)与 flat(semantic_guard.enabled: false);
172
+ * 行尾先归一(Windows CRLF 残留致行式正则整体失配)。
173
+ * @param {string} specBase - spec 根(local.yaml 所在目录)
174
+ * @returns {boolean}
175
+ */
176
+ export function readSemanticGuardEnabled(specBase) {
177
+ try {
178
+ const yamlPath = join(specBase, 'local.yaml')
179
+ if (!existsSync(yamlPath)) return true
180
+ const text = String(readFileSync(yamlPath, 'utf8')).replace(/\r\n?/g, '\n')
181
+ let inSection = false
182
+ let sectionIndent = -1
183
+ for (const line of text.split('\n')) {
184
+ // flat 形态优先:键名带点,不与嵌套段头冲突
185
+ const flat = line.match(/^\s*semantic_guard\.enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
186
+ if (flat) return parseEnabledValue(flat[1])
187
+ const section = line.match(/^(\s*)semantic_guard\s*:\s*(?:#.*)?$/)
188
+ if (section) {
189
+ inSection = true
190
+ sectionIndent = section[1].length
191
+ continue
192
+ }
193
+ if (!inSection) continue
194
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
195
+ // 缩进回落到段头层级(或更浅)= semantic_guard 段结束,之后的 enabled 不属于它
196
+ if (/^\s*/.exec(line)[0].length <= sectionIndent) {
197
+ inSection = false
198
+ continue
199
+ }
200
+ const kv = line.match(/^\s*enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
201
+ if (kv) return parseEnabledValue(kv[1])
202
+ }
203
+ return true
204
+ } catch {
205
+ return true
206
+ }
207
+ }
@@ -282,9 +282,14 @@ export class SillyHubMcpClient {
282
282
  }
283
283
 
284
284
  if (!res.ok) {
285
+ // 401 状态留痕(2026-09-11 用户实测反馈:token 失效被 probe 报成 daemon-unreachable,
286
+ // 误导排障方向——吊销/过期的 shmcp_ 是「重连再配对」能解的,不是 daemon 的问题)。
287
+ // getLastInitStatus() 供 probeSillyHub 类型化 reason;成功/其他失败置 null(不误判旧状态)。
288
+ this._lastInitHttpStatus = res.status;
285
289
  if (!quiet) console.warn(`[sillyhub-mcp] initialize → HTTP ${res.status}`);
286
290
  return false;
287
291
  }
292
+ this._lastInitHttpStatus = null;
288
293
 
289
294
  // 优先取 header mcp-session-id(FastMCP 实测返回);缺失再查 body _meta.sessionId。
290
295
  // ⚠️ 无论 header 是否有 session,都必须读完 body(消费 SSE 流)——FastMCP 在
@@ -391,6 +396,15 @@ export class SillyHubMcpClient {
391
396
  return result !== null;
392
397
  }
393
398
 
399
+ /**
400
+ * 最近一次 initialize 握手的 HTTP 状态码(null = 无记录/成功/非 HTTP 失败)。
401
+ * 消费方:probeSillyHub 的 token 失效类型化(401 → reason='mcp-token-invalid',
402
+ * 区别于 daemon-unreachable——2026-09-11 用户实测:吊销 token 被误报 daemon 不可达)。
403
+ */
404
+ getLastInitStatus() {
405
+ return this._lastInitHttpStatus ?? null;
406
+ }
407
+
394
408
  /**
395
409
  * 列出 daemon 暴露的 MCP tools(JSON-RPC `tools/list`,task-11 路径A 探测用)。
396
410
  *