sillyspec 3.31.0 → 3.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +10 -6
  5. package/.claude/skills/sillyspec-quick/SKILL.md +105 -7
  6. package/.zcodeignore +66 -0
  7. package/AGENTS.md +16 -16
  8. package/assets/command-cards/flow.md +9 -3
  9. package/assets/command-cards/run-quick.md +4 -1
  10. package/package.json +2 -2
  11. package/src/agent-session-log.js +27 -5
  12. package/src/config-schema.js +21 -0
  13. package/src/datetime.js +51 -0
  14. package/src/db.js +10 -3
  15. package/src/decision-distill.js +21 -12
  16. package/src/design-facts.js +46 -6
  17. package/src/flow-draft.js +468 -95
  18. package/src/flow-parity.js +199 -5
  19. package/src/flow-review.js +136 -6
  20. package/src/flow.js +786 -145
  21. package/src/foreign-declared.js +75 -3
  22. package/src/fr-index.js +148 -162
  23. package/src/git-helper.js +16 -3
  24. package/src/hooks/worktree-guard.js +16 -5
  25. package/src/hunk-attribution.js +28 -5
  26. package/src/index.js +186 -19
  27. package/src/knowledge-classify.js +3 -1
  28. package/src/knowledge-digest.js +55 -26
  29. package/src/knowledge-match.js +135 -7
  30. package/src/knowledge-stats.js +105 -4
  31. package/src/knowledge-vector.js +160 -0
  32. package/src/machine-interface.js +90 -2
  33. package/src/module-impact.js +22 -1
  34. package/src/modules.js +9 -0
  35. package/src/progress/change-registry.js +20 -0
  36. package/src/progress/stage-machine.js +4 -7
  37. package/src/progress.js +8 -1
  38. package/src/quicklog.js +20 -0
  39. package/src/redomain.js +23 -7
  40. package/src/route-hindsight.js +276 -0
  41. package/src/run/archive-distill.js +3 -1
  42. package/src/run/command.js +65 -12
  43. package/src/run/complete-handlers.js +219 -5
  44. package/src/run/complete.js +99 -22
  45. package/src/run/gate-snapshot.js +15 -0
  46. package/src/run/gates.js +60 -4
  47. package/src/run/prompt.js +159 -31
  48. package/src/run/quick-audit.js +66 -7
  49. package/src/run/shared.js +6 -11
  50. package/src/run/stage.js +5 -1
  51. package/src/run/verify-quality-scan.js +84 -0
  52. package/src/scan-facts.js +2 -1
  53. package/src/scope-audit.js +138 -5
  54. package/src/sentinel-assertions.js +67 -16
  55. package/src/stage-contract-engine.js +4 -1
  56. package/src/stage-contract-spec.js +103 -4
  57. package/src/stage-contract.js +55 -7
  58. package/src/stages/brainstorm.js +27 -14
  59. package/src/stages/knowledge.js +4 -3
  60. package/src/stages/plan-postcheck.js +98 -3
  61. package/src/stages/plan.js +22 -2
  62. package/src/stages/quick.js +1 -1
  63. package/src/stages/verify.js +2 -1
  64. package/src/task-review.js +1 -1
  65. package/src/task-tick.js +121 -0
  66. package/src/taskcard-frontmatter.js +31 -0
  67. package/src/ui-visual.js +43 -8
  68. package/src/verify-postcheck.js +518 -38
  69. package/src/verify-probes.js +216 -5
  70. package/src/watcher.js +1067 -1031
  71. package/src/worktree-apply.js +9 -1
  72. package/src/worktree.js +21 -6
  73. package/templates/agents-instruction.md +15 -15
  74. package/templates/prompts/taskcard-rules.md +1 -0
@@ -10,13 +10,21 @@
10
10
  * 厚道有轻量变更缺的审计资产;机器合成勿手改。
11
11
  * ③ harvestSlot4Decision:design 槽4(风险与死路)实质作答收割合成 decisions.md——轻量变更决策
12
12
  * 产出为零的补口(死路与风险取舍正是 decisions.md 该记的内容;已有文件不覆盖)。
13
+ * ④ writeCloseTraceArtifacts / buildThinSnapshotRows:双通道收尾留痕统一写
14
+ * (2026-10-07-unify-close-trace)——thin flow done 与 heavy execute --done 共用一处一次
15
+ * 写齐四件(沉淀资产面 + 对账快照面,sha256 双套同锚),新变更两通道留痕对称。
13
16
  */
14
17
  import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
15
18
  import { join, relative } from 'node:path'
19
+ import { createHash } from 'node:crypto'
16
20
  import { execFileSync } from 'node:child_process'
17
- import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
21
+ import { splitOwnVsForeignDiffFiles, commitAttributionForChange, isForeignByCommit, parseChangeNamesFromSubject } from './foreign-declared.js'
18
22
  import { collectModuleMaps, normalizeMapPath } from './module-resolve.js'
19
23
  import { writeAtomicSync } from './fs-atomic.js'
24
+ import { readV2SectionAnswer } from './flow-review.js'
25
+ import { DESIGN_QUESTIONS } from './flow-draft.js'
26
+ import { pathMatches } from './change-list.js'
27
+ import { classifyToolScaffold, filterDeliverableFiles } from './worktree-apply.js'
20
28
 
21
29
  /**
22
30
  * 模块文档对账(advisory)+ 结构化落盘面(2026-09-27-thin-module-scope-persist)。
@@ -144,7 +152,13 @@ export function harvestSlot4Decision({ changeDir, change }) {
144
152
  if (inSlot && (/^<!--/.test(line) || /^#{1,6}\s/.test(line))) break
145
153
  if (inSlot) buf.push(line)
146
154
  }
147
- const answer = buf.join('\n').trim()
155
+ let answer = buf.join('\n').trim()
156
+ // v2 纯 markdown(2026-10-04-thin-docs-v2):无槽标记时改读「风险与死路」节正文(复用
157
+ // flow-review 的 v2 节读取——剥离问题原文行;单一源 DESIGN_QUESTIONS 防镜像漂移)
158
+ if (!inSlot) {
159
+ const risksSec = DESIGN_QUESTIONS.sections.find((s) => s.key === 'risks')
160
+ answer = readV2SectionAnswer(dText, risksSec.heading, DESIGN_QUESTIONS)
161
+ }
148
162
  if (!answer || /^不适用/.test(answer)) return { harvested: false, reason: '槽4 空/不适用' }
149
163
  const text = [
150
164
  '---',
@@ -177,11 +191,61 @@ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision,
177
191
  * 非 .sillyspec/ 全留 + 本变更目录(治理工件)+ .sillyspec/docs/ 交付文档(dogfood 模块卡
178
192
  * 是交付物——2026-09-27-gate-docs-cleanup 评审 P2 实证旧口径把已提交模块卡漏出审计 patch)。
179
193
  * 他侧 .sillyspec/changes/**(quicklog/knowledge WIP)仍滤除。反斜杠归一。
194
+ *
195
+ * 提交事实归属补位(2026-10-05-diff-commit-attribution):现状「已提交即本变更的」前提是
196
+ * 单会话——多会话共享仓 baseline..HEAD 混入他侧交付(评审 P2 实证:他会话 knowledge-stats
197
+ * hunk 冻进本变更审计件)。在现状过滤后剔除「窗口内全部提交均属他侧变更名」的交付文件。
198
+ * 与否决决策 sentinel-evidence-freeze⑤ 的分界:按提交 message 变更名(既成事实)切,不按
199
+ * 任何变更的声明清单(该决策禁的是陈旧声明的意图抢夺)。best-effort:变更名自 ownPrefix
200
+ * 反解,baseline 自 flow-state.yaml 读取;不可得(非 git 仓/无 flow-state/git 失败/归档后
201
+ * 目录已移走)= 行为与旧版完全一致。保守:被无后缀裸提交触碰过的文件不剔(fail-closed)。
180
202
  */
181
- export function filterCommittedFace(committedRaw, ownPrefix) {
182
- return committedRaw
203
+ export function filterCommittedFace(committedRaw, ownPrefix, opts = {}) {
204
+ const base = committedRaw
183
205
  .map((f) => String(f).replace(/\\/g, '/'))
184
206
  .filter((p) => !p.startsWith('.sillyspec/') || p.startsWith(ownPrefix) || p.startsWith('.sillyspec/docs/'))
207
+ try {
208
+ const cwd = opts.cwd || process.cwd()
209
+ const pm = /^\.sillyspec\/changes\/([^/]+)\/$/.exec(String(ownPrefix || ''))
210
+ if (!pm) return base
211
+ const specBase = opts.specBase || join(cwd, '.sillyspec')
212
+ const attr = commitAttributionForChange(cwd, specBase, pm[1], opts.baselineCommit || null)
213
+ if (!attr) return base
214
+ return base.filter((p) => {
215
+ if (p.startsWith('.sillyspec/')) return true // 治理面归属已由上方现状口径裁决
216
+ return !isForeignByCommit(attr.get(p), pm[1])
217
+ })
218
+ } catch { return base }
219
+ }
220
+
221
+ /**
222
+ * patch 冻结面漂移检测(2026-10-05-disposition-refreeze-drift,主清单 P1):处置重入审计
223
+ * 错位——评审发现处置涉及代码修改后重跑 flow done,patch 子步幂等跳过让 change.patch/
224
+ * review.json 停在处置前时点(2026-10-05-review-promise-negation 先例 905397ef;
225
+ * 2026-10-05-flowdone-lintfail-output 收编活体复现:评审 P1 处置提交后不带 --refreeze 重跑,
226
+ * 归档件缺处置面)。
227
+ *
228
+ * 判定:freezeHead(change-patch.json.head 冻结锚)..HEAD 窗口内存在「本变更名后缀提交」即
229
+ * 漂移——按提交 message 变更名归属(parseChangeNamesFromSubject,与 filterCommittedFace
230
+ * 同口径的既成事实切分,非声明抢文件)。仅他侧后缀/裸提交不触发(他侧面归属切分另有防线,
231
+ * 裸提交保守不动作)。检测自身失败按无漂移(退现状幂等跳过行为,fail-safe 不新造阻断面)。
232
+ * @param {{ cwd: string, change: string, freezeHead: string|null }} opts
233
+ * @returns {{ drifted: boolean, ownCommits: string[], head: string|null }} ownCommits=本变更后缀提交的 subject 列表(截 80 字)
234
+ */
235
+ export function detectPatchDrift({ cwd, change, freezeHead }) {
236
+ try {
237
+ if (!freezeHead) return { drifted: false, ownCommits: [], head: null }
238
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 30000, windowsHide: true }).trim()
239
+ if (!head || head === freezeHead) return { drifted: false, ownCommits: [], head }
240
+ const log = execFileSync('git', ['log', '--format=%x00%s', `${freezeHead}..HEAD`], { cwd, encoding: 'utf8', timeout: 30000, windowsHide: true })
241
+ const own = []
242
+ for (const block of String(log).split('\x00')) {
243
+ const subject = block.split('\n').map((l) => l.trim()).filter(Boolean)[0]
244
+ if (!subject) continue
245
+ if (parseChangeNamesFromSubject(subject).includes(change)) own.push(subject.slice(0, 80))
246
+ }
247
+ return { drifted: own.length > 0, ownCommits: own, head }
248
+ } catch { return { drifted: false, ownCommits: [], head: null } }
185
249
  }
186
250
 
187
251
  /**
@@ -194,7 +258,11 @@ export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive
194
258
  const deliverable = (f) => !String(f).replace(/\\/g, '/').startsWith('.sillyspec/')
195
259
  let dirty = []
196
260
  try {
197
- const out = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8', timeout: 30000, windowsHide: true })
261
+ // -uall(2026-10-08-thin-done-dirty-gate-and-paren-attribution):全新目录会折叠成
262
+ // `dir/` token(porcelain 缺省形态)——readFileSync(目录) 静默 skip,--freeze-dirty
263
+ // 声称并入却漏目录内交付文件(dg-freeze 夹具实证 src/ 折叠)。逐文件展开与
264
+ // scope-audit/flow.js 状态采集口径对齐。
265
+ const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, encoding: 'utf8', timeout: 30000, windowsHide: true })
198
266
  for (const line of String(out).split('\n')) {
199
267
  if (!line || line.length < 4) continue
200
268
  const p = line.slice(3).trim().replace(/^"|"$/g, '')
@@ -214,6 +282,132 @@ export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive
214
282
  return { files: [...committed], dirtyAdded: [], dirtyWarned: ownDirty }
215
283
  }
216
284
 
285
+ /**
286
+ * thin 通道三态行构造(writeCloseTraceArtifacts 的 snapObj.rows 数据源,纯函数):
287
+ * ownFiles 过 filterDeliverableFiles(与 scope-audit actual 侧同口径——治理工件目录不进
288
+ * 快照行)× design 清单 pathMatches 判三态(planned/unplanned;与主链路三态判定同款);
289
+ * stats(collectNumstatByPath 产物 Map)缺档落 null 行数(不出伪数据);planEntries 空/缺省
290
+ * → 实际侧 only 行(无 verdict,与主链路计划侧降级同语义)。清单条目未命中冻结面 →
291
+ * untouched 0/0 补行(跨仓条目带 crossRepo 保持 ⊘ 形态,主链路补行同语义)。
292
+ * @returns {{ rows: Array<object>, totals: { files: number, additions: number, deletions: number } }}
293
+ */
294
+ export function buildThinSnapshotRows({ ownFiles, stats, planEntries }) {
295
+ const plan = Array.isArray(planEntries) ? planEntries : []
296
+ const statMap = stats instanceof Map ? stats : new Map()
297
+ const rowFiles = filterDeliverableFiles([...new Set((Array.isArray(ownFiles) ? ownFiles : [])
298
+ .map((f) => String(f).replace(/\\/g, '/')).filter(Boolean))]).sort()
299
+ const rows = []
300
+ const matched = new Set()
301
+ for (const f of rowFiles) {
302
+ const st = statMap.get(f) || {}
303
+ const row = {
304
+ path: f,
305
+ additions: Number.isFinite(st.additions) ? st.additions : null,
306
+ deletions: Number.isFinite(st.deletions) ? st.deletions : null,
307
+ kind: st.kind || 'modified',
308
+ }
309
+ if (plan.length > 0) {
310
+ const entry = plan.find((e) => e && pathMatches(f, e.path))
311
+ if (entry) {
312
+ matched.add(entry.path)
313
+ row.planned = entry.operation || null
314
+ row.verdict = 'planned'
315
+ } else {
316
+ row.planned = null
317
+ row.verdict = 'unplanned'
318
+ const facility = classifyToolScaffold(f)
319
+ if (facility) row.facility = facility
320
+ }
321
+ }
322
+ rows.push(row)
323
+ }
324
+ for (const e of plan) {
325
+ if (!e || matched.has(e.path)) continue
326
+ rows.push({
327
+ path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
328
+ kind: 'modified', verdict: 'untouched',
329
+ ...(e.repo ? { crossRepo: e.repo } : {}),
330
+ })
331
+ }
332
+ let additions = 0
333
+ let deletions = 0
334
+ for (const r of rows) {
335
+ if (Number.isFinite(r.additions)) additions += r.additions
336
+ if (Number.isFinite(r.deletions)) deletions += r.deletions
337
+ }
338
+ return { rows, totals: { files: rows.length, additions, deletions } }
339
+ }
340
+
341
+ /**
342
+ * heavy 通道沉淀资产面投影(writeCloseTraceArtifacts 的 files/totals 数据源,纯函数;
343
+ * 2026-10-07-unify-close-trace,评审 P3 覆盖清偿抽纯函数):rows → 主仓实改行——
344
+ * untouched 0/0 是声明不是改动、crossRepo 行 patch 不在主仓,均不入;verdict 缺失行
345
+ * (计划侧降级形态)按实改保留;null 行数不计合计(不出伪数据)。
346
+ * @returns {{ rows: object[], files: string[], totals: { files: number, additions: number, deletions: number } }}
347
+ */
348
+ export function projectTraceFaceRows(rows) {
349
+ const face = (Array.isArray(rows) ? rows : [])
350
+ .filter((r) => r && typeof r === 'object' && !r.crossRepo && r.verdict !== 'untouched')
351
+ let additions = 0
352
+ let deletions = 0
353
+ for (const r of face) {
354
+ if (Number.isFinite(r.additions)) additions += r.additions
355
+ if (Number.isFinite(r.deletions)) deletions += r.deletions
356
+ }
357
+ return { rows: face, files: face.map((r) => r.path), totals: { files: face.length, additions, deletions } }
358
+ }
359
+
360
+ /**
361
+ * 双通道收尾留痕统一写(2026-10-07-unify-close-trace,缺陷 thin-flow-done-no-scope-
362
+ * audit-snapshot 修复方向③):thin(flow done)与 heavy(execute --done)此前各写各的
363
+ * 留痕(沉淀资产面 change.patch/change-patch.json vs 对账快照面 scope-audit.patch/
364
+ * scope-audit.json),消费方各认一份致每类变更恰好一张卡失真——本函数一处写齐四件,
365
+ * sha256/patchStatus 双套同锚(同一 patchText 一次计算:change.patch ≡ scope-audit.patch
366
+ * 字节一致、两 json 同 hash,两套留痕不可能漂移)。patchText 空/null → 双套同标 failed、
367
+ * 不落 patch 文件(heavy 旧「空串当 ok」形态收口)。读侧兼容链(快照 > change-patch
368
+ * 回放 > 实时区间,2026-10-07-scope-audit-thin-patch-replay)不动——本函数让新变更两套
369
+ * 齐备,回退退化为存量归档兜底。
370
+ *
371
+ * @param {string} changeDir 变更目录(活跃或归档侧——归档竞态由调用方解析)
372
+ * @param {object} snapObj scope-audit.json 主体(mode/ok/degradedReason/baseAnchor/totals/
373
+ * rows/excluded/note[/repos];closedBy 由调用方携带标收尾通道——读侧回放按它标注,
374
+ * 缺省 'execute --done' 兼容旧快照)
375
+ * @param {object} meta change-patch.json 增量键(note/moduleScope 等,展开在 totals 后)
376
+ * @returns {{ patchStatus: 'ok'|'failed', patchSha256: string|null }}
377
+ */
378
+ export function writeCloseTraceArtifacts({ changeDir, change, baseline, head, files, metaTotals, patchText, savedAt, snapObj, meta = {} }) {
379
+ const fileList = Array.isArray(files) ? files : []
380
+ const patchOk = typeof patchText === 'string' && patchText.length > 0
381
+ const patchBody = patchOk ? (patchText.endsWith('\n') ? patchText : patchText + '\n') : null
382
+ const patchSha256 = patchBody
383
+ ? createHash('sha256').update(patchBody.replace(/\r\n/g, '\n'), 'utf8').digest('hex')
384
+ : null
385
+ if (patchOk) {
386
+ writeFileSync(join(changeDir, 'change.patch'), patchBody)
387
+ writeFileSync(join(changeDir, 'scope-audit.patch'), patchBody)
388
+ }
389
+ writeFileSync(join(changeDir, 'change-patch.json'), JSON.stringify({
390
+ change,
391
+ baseline: baseline ?? null,
392
+ head: head ?? null,
393
+ files: fileList,
394
+ totals: metaTotals && typeof metaTotals === 'object'
395
+ ? metaTotals
396
+ : { files: fileList.length, additions: 0, deletions: 0 },
397
+ savedAt,
398
+ ...meta,
399
+ ...(patchSha256 ? { patchSha256 } : {}),
400
+ patchStatus: patchOk ? 'ok' : 'failed',
401
+ }, null, 2) + '\n')
402
+ writeFileSync(join(changeDir, 'scope-audit.json'), JSON.stringify({
403
+ ...snapObj,
404
+ ...(patchSha256 ? { patchSha256 } : {}),
405
+ patchStatus: patchOk ? 'ok' : 'failed',
406
+ savedAt,
407
+ }, null, 2) + '\n')
408
+ return { patchStatus: patchOk ? 'ok' : 'failed', patchSha256 }
409
+ }
410
+
217
411
  /**
218
412
  * 实测面回填(修复③:ledger 子步断点续跑 skip 后回执不失忆)——从 verify-runs 最近
219
413
  * test-result.json(带 change 键)重建摘要;无匹配返回 null。
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { existsSync, readFileSync } from 'node:fs'
20
20
  import { join } from 'node:path'
21
+ import { DESIGN_QUESTIONS as DESIGN_QUESTIONS_FALLBACK } from './flow-draft.js'
21
22
 
22
23
  /** 高危交付语义承诺词(一票升级)。2026-09-25-platform-feedback-batch2 D 收敛:移除「幂等」
23
24
  * (实现手段非交付语义,由 diff 原语面覆盖);保留的七个词均为面向用户的交付语义承诺。 */
@@ -26,8 +27,34 @@ const PROMISE_RE = /at[- ]least[- ]once|exactly[- ]once|不丢失|不重复|不
26
27
  /** adopted(头脑风暴预段)design 无槽时的机制词全文扫描(窄表防误报)。 */
27
28
  const MECHANISM_RE = /乱序|并发写|竞态|死锁|事务隔离|AbortSignal|多实例串|游标推进/
28
29
 
29
- /** 交付 diff 危险原语(物证面)。 */
30
- const PRIMITIVE_RE = /asyncio\.(?:Lock|Event|Queue|gather)|threading\.|with_for_update|IntegrityError|ON CONFLICT|AbortSignal|AbortController|signal\.abort|\.rollback\(|since=|\bcursor\b/i
30
+ /** design 作答面否定语境消解(2026-10-05-review-promise-negation):盲维第 4 问作答讨论
31
+ * 作用域风险时,「无串台面」「不会串台」「杜绝串台」等否定短语按字面命中 PROMISE_RE 一票
32
+ * 升级(实测:两行 console.log 的变更被定档高危起子代理评审)。「串台」是名词性风险词——
33
+ * 承诺语态必有断言修饰(与「不丢失」类「不X」形态承诺词本质不同),否定前缀 + 短距内的
34
+ * 「串台」先替换为占位符再扫;「解决串台」「仍有串台」等非否定语境保留一票升级。仅 design
35
+ * 作答面应用——input/proposal/requirements 承载用户原话,承诺口径零放松。后缀否定
36
+ * (「串台为零」)不覆盖:中文技术作答主流为前缀否定,为此加规则的复杂度不划算。
37
+ * 整词「不串台」追加(2026-10-06-review-anchor-and-negation 实测:词表无裸「不」形态,
38
+ * design 作答「前缀过滤不串台」字面命中一票升级)——只加三字整词不加裸「不」前缀:
39
+ * 「不排除串台」「不排除有串台」是风险自认(保留升级),裸前缀会把它们误消解。 */
40
+ const NEGATED_CROSSTALK_RE = /(?:不会|不存在|没有|无|零|防|杜绝|避免|不含|免)[^\n]{0,8}?串台|不串台/g
41
+
42
+ /** 风险自认形态(评审 P2 修复):「无法杜绝串台」「难免串台」「避免不了串台」否定的是
43
+ * 「阻止」而非风险本身——语义=承认风险在场,必须保留一票升级。这类形态在场时整段保守
44
+ * 不消解(宁可多评不可漏报);「杜绝串台」等安全声明无引导词不受影响。 */
45
+ const RISK_ADMITTING_RE = /(?:无法|未能|没能|不可能不)[^\n]{0,8}?(?:杜绝|避免|防止|阻止|消除|防)[^\n]{0,8}?串台|(?:难免|避免不了|避不了|少不了)[^\n]{0,4}?串台/
46
+ function stripNegatedCrosstalk(text) {
47
+ const t = String(text || '')
48
+ if (RISK_ADMITTING_RE.test(t)) return t
49
+ // 占位符本身禁含「串台」二字——消解产物若残留原词,PROMISE_RE 照样命中(首版实测自坑)
50
+ return t.replace(NEGATED_CROSSTALK_RE, '〔已消解〕')
51
+ }
52
+
53
+ /** 交付 diff 危险原语(物证面)。cursor 收窄(2026-10-06-litest-p1-fixes 实测:原 \bcursor\b
54
+ * 意图抓 DB 游标,却撞上本仓 harness 名 cursor——纯文案变更因测试断言 includes('cursor')
55
+ * 被判危险原语起子代理评审):只认 DB-API 方法调用 conn.cursor() 与分页协议词 next_cursor
56
+ * 用法形态;纯名词 cursor / cursor-agent / detectCursor(本仓既有词汇)不命中。 */
57
+ const PRIMITIVE_RE = /asyncio\.(?:Lock|Event|Queue|gather)|threading\.|with_for_update|IntegrityError|ON CONFLICT|AbortSignal|AbortController|signal\.abort|\.rollback\(|since=|\.cursor\(|\bnext_cursor\b/i
31
58
 
32
59
  /** 豁免抽查采样:确定性哈希 1/4 定额(SALT 防碰巧连坐,调表需同步核对既有夹具零碰撞)。 */
33
60
  const SAMPLE_SALT = 7
@@ -50,6 +77,27 @@ function readSlotAnswer(text, markerRe) {
50
77
  return buf.join('\n').trim()
51
78
  }
52
79
 
80
+ /**
81
+ * 读 v2 纯 markdown design 节作答(2026-10-04-thin-docs-v2):节标题行到下一节标题之间的
82
+ * 非空行,剥离四问原文行(DESIGN_QUESTIONS 单一源同源引入——独立字符串会被评审实证为
83
+ * 镜像非同源)与引导行(> 开头)。空白返回 ''(调用方按未作答处理)。
84
+ */
85
+ export function readV2SectionAnswer(text, heading, designQuestions) {
86
+ const dq = designQuestions || DESIGN_QUESTIONS_FALLBACK
87
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
88
+ const start = lines.findIndex((l) => l.trim() === `## ${heading}`)
89
+ if (start === -1) return ''
90
+ let end = lines.findIndex((l, i) => i > start && /^##\s/.test(l))
91
+ if (end === -1) end = lines.length
92
+ const qLines = new Set()
93
+ for (const sec of (dq.sections || [])) {
94
+ if (sec.heading === heading) for (const q of sec.lines) qLines.add(q)
95
+ }
96
+ return lines.slice(start + 1, end)
97
+ .filter((l) => { const t = l.trim(); return t && !t.startsWith('>') && !t.startsWith('##') && !qLines.has(t) })
98
+ .join('\n').trim()
99
+ }
100
+
53
101
  /** 剥 MACHINE-DRAFT 机器段(design.md 的问题模板自带「串台」等承诺词字样——模板自污染面,
54
102
  * 扫描只看 agent 作答面;proposal/requirements 的机器段承载用户原话不剥)。 */
55
103
  function stripMachineSections(text) {
@@ -58,6 +106,23 @@ function stripMachineSections(text) {
58
106
  .replace(/<!--\s*MACHINE-DRAFT:[\w.-]+:[^\n]*:begin[^\n]*-->[\s\S]*?<!--\s*MACHINE-DRAFT:[\w.-]+:end\s*-->/g, '')
59
107
  }
60
108
 
109
+ /**
110
+ * 剥 v2 纯 markdown 起草的问题文本行(2026-10-04-thin-docs-v2):四问原文含「串台」承诺词
111
+ * 与并发机制词——v1 靠 MACHINE-DRAFT 段剥离防模板自污染,v2 无标记可剥,按行删(四问
112
+ * 原文=DESIGN_QUESTIONS 单一源逐字;节标题/引导行一并删——作答才是扫描面)。
113
+ */
114
+ function stripV2QuestionLines(text) {
115
+ const drop = new Set()
116
+ for (const sec of (DESIGN_QUESTIONS_FALLBACK.sections || [])) {
117
+ drop.add(`## ${sec.heading}`)
118
+ for (const q of sec.lines) drop.add(q)
119
+ }
120
+ return String(text || '')
121
+ .split('\n')
122
+ .filter((l) => { const t = l.trim(); return !(drop.has(t) || t.startsWith('>')) })
123
+ .join('\n')
124
+ }
125
+
61
126
  /**
62
127
  * 危险证据定档。@returns {{required: boolean, reasons: string[], exemptEvidence: string[], sampled: boolean}}
63
128
  */
@@ -72,7 +137,7 @@ export function classifyReviewNeed({ changeDir, input = '', patchText = null, ed
72
137
  for (const f of ['proposal.md', 'requirements.md', 'design.md']) {
73
138
  try {
74
139
  const t = readFileSync(join(changeDir, f), 'utf8').replace(/\r\n/g, '\n')
75
- texts.push(f === 'design.md' ? stripMachineSections(t) : t)
140
+ texts.push(f === 'design.md' ? stripNegatedCrosstalk(stripV2QuestionLines(stripMachineSections(t))) : t)
76
141
  } catch { /* 缺件由 redraft/槽位门兜底 */ }
77
142
  }
78
143
  const all = texts.join('\n')
@@ -88,6 +153,12 @@ export function classifyReviewNeed({ changeDir, input = '', patchText = null, ed
88
153
  if (!answer) exemptEvidence.push('盲维四问未作答(将由槽位门拦收)')
89
154
  else if (/^不适用/.test(answer)) exemptEvidence.push('盲维四问自述不适用')
90
155
  else reasons.push('盲维四问有实质作答(存在时序/并发/切换/作用域风险面)')
156
+ } else if (/^##\s*边界与并发/m.test(dText)) {
157
+ // v2 纯 markdown(2026-10-04-thin-docs-v2):边界节正文作答判定(同 v1 三态语义)
158
+ const answer = readV2SectionAnswer(dText, DESIGN_QUESTIONS_FALLBACK.sections[2].heading, DESIGN_QUESTIONS_FALLBACK)
159
+ if (!answer) exemptEvidence.push('盲维四问未作答(将由 v2 工件校验拦收)')
160
+ else if (/^不适用/.test(answer)) exemptEvidence.push('盲维四问自述不适用')
161
+ else reasons.push('盲维四问有实质作答(存在时序/并发/切换/作用域风险面)')
91
162
  } else if (MECHANISM_RE.test(dText)) {
92
163
  reasons.push('头脑风暴设计含并发/时序类机制词(adopted 扫描)')
93
164
  } else {
@@ -120,17 +191,47 @@ export function classifyReviewNeed({ changeDir, input = '', patchText = null, ed
120
191
  /**
121
192
  * 评审员任务书(flow done 首次命中需评审时打印——agent 起干净上下文子代理执行)。
122
193
  * 材料有界(change.patch 为冻结件;治理工件已含字节帽意识);请求预算硬帽防失控勘察(R14 教训)。
194
+ * head(评审对象锚,2026-10-06-review-anchor-and-negation):调用方传当前 git HEAD——任务书
195
+ * 印出完整 sha 并要求 review.json 的 reviewedAgainst 照抄;漂移重冻结据此区分「对着旧冻结面的
196
+ * 过期评审」与「对着当前 HEAD 的刚完成评审」(实测竞态:复审 PASS 落盘 26 秒后被误隔离)。
123
197
  */
124
- export function renderReviewerTaskbook({ change, changeDir }) {
125
- return [
198
+ export function renderReviewerTaskbook({ change, changeDir, head = null }) {
199
+ const headFull = String(head || '').trim()
200
+ // 前轮 findings 注入(2026-10-07-wave-auto-adopt-review-dedup task-02):复审场景(前轮 FAIL/
201
+ // 评审隔离重评)下任务书带上前轮清单——已报项只验修复与回归,重点找新增问题,勿整轮重报
202
+ // (postmortem 实证评审员两轮报同类问题,多跑一整轮成本)。读不到/无 findings 零注入
203
+ // (首评任务书与现状逐字一致)。
204
+ let priorFindingsMd = ''
205
+ try {
206
+ const prev = JSON.parse(readFileSync(join(changeDir, 'review.json'), 'utf8'))
207
+ if (Array.isArray(prev.findings) && prev.findings.length > 0) {
208
+ priorFindingsMd = [
209
+ ``,
210
+ `【前轮评审 findings(${prev.findings.length} 项,前轮 verdict=${String(prev.verdict || '?')})——本轮是复审】`,
211
+ ...prev.findings.slice(0, 20).map((f, i) => ` ${i + 1}. [${f && f.severity ? f.severity : '?'}] ${f && f.title ? f.title : String(f).slice(0, 120)}`),
212
+ ``,
213
+ ` 复审优先级:已报项只验「修复到位 + 未引入回归」(逐条给修复验证锚点),把请求预算主力投向新增问题——`,
214
+ ` 勿把前轮清单原样重报一遍(换视角有独立价值,重复报告挤占 12 次请求预算)。`,
215
+ ].join('\n')
216
+ }
217
+ } catch { /* 无 review.json / 损坏 → 首评形态,零注入 */ }
218
+ const taskbook = [
126
219
  `⚖️ 独立评审任务书 — ${change}`,
127
220
  `══════════════════════════════════════`,
128
221
  `【角色】你是独立评审员(干净上下文,未参与实现)——拿承诺对代码,不信自述,零误报标准。`,
222
+ ...(headFull ? [
223
+ `【评审对象】工作区实态 + git HEAD(${headFull})——change.patch 冻结面若落后于该 HEAD,以`,
224
+ ` 工作区实态与 git log/show 核对为准;review.json 的 reviewedAgainst 字段照抄上面的完整 sha。`,
225
+ ] : [
226
+ `【评审对象】工作区实态 + git HEAD——review.json 的 reviewedAgainst 字段填 git rev-parse HEAD`,
227
+ ` 输出的完整 sha(评审对象锚,缺失时收口漂移检测会把本评审判为过期隔离重评)。`,
228
+ ]),
129
229
  ``,
130
230
  `【材料(按需 Read,请求预算硬帽 12 次——超帽即止,包不足以作答写 cannot_verify)】`,
131
231
  ` - ${join(changeDir, 'requirements.md')}(FR 承诺 + 每条 FR 的测试绑定作答)`,
132
232
  ` - ${join(changeDir, 'design.md')}(设计承诺:做法/接口契约/盲维四问作答/风险)`,
133
233
  ` - ${join(changeDir, 'change.patch')}(交付 diff 冻结件——代码事实的唯一来源)`,
234
+ ...(priorFindingsMd ? priorFindingsMd.split('\n') : []),
134
235
  ``,
135
236
  `【检查单(逐条作答,P1=承诺被违反或有数据正确性风险)】`,
136
237
  ` 1. FR↔实现↔测试三方一致:每条 FR 的实现兑现承诺?绑定作答声称的测试真存在且断言该行为?`,
@@ -148,6 +249,7 @@ export function renderReviewerTaskbook({ change, changeDir }) {
148
249
  `{`,
149
250
  ` "schemaVersion": 1, "change": "${change}", "reviewer": "subagent",`,
150
251
  ` "verdict": "PASS" | "FAIL",`,
252
+ ` "reviewedAgainst": "<评审对象 HEAD 完整 sha——照抄任务书标注的 sha>",`,
151
253
  ` "findings": [ { "severity": "P1|P2|P3", "title": "…", "evidence": "代码锚点+机理一句话", "location": "file:line" } ],`,
152
254
  ` "dimensionNotes": { "乱序": "ok|finding|n/a", "并发": "ok|finding|n/a", "切换": "ok|finding|n/a", "作用域": "ok|finding|n/a" },`,
153
255
  ` "reviewedAt": "<ISO 时间>"`,
@@ -155,10 +257,13 @@ export function renderReviewerTaskbook({ change, changeDir }) {
155
257
  ``,
156
258
  `完成后由主会话重跑:sillyspec flow done --change ${change}(断点续,已完成子步幂等跳过)`,
157
259
  ].join('\n')
260
+ return taskbook
158
261
  }
159
262
 
160
263
  /**
161
264
  * review.json 校验(三态:ok / schema 错误清单)。FAIL 或含 P1 由调用方拦截。
265
+ * reviewedAgainst(评审对象锚,2026-10-06-review-anchor-and-negation):可选字段——缺省
266
+ * 容忍(旧产物向后兼容),存在时须为 7-40 位 hex sha。
162
267
  */
163
268
  export function validateReviewJson(path) {
164
269
  let raw
@@ -168,6 +273,10 @@ export function validateReviewJson(path) {
168
273
  const errors = []
169
274
  if (r.schemaVersion !== 1) errors.push('schemaVersion 必须为 1')
170
275
  if (!['PASS', 'FAIL'].includes(r.verdict)) errors.push('verdict 必须为 PASS|FAIL')
276
+ if (r.reviewedAgainst !== undefined
277
+ && (typeof r.reviewedAgainst !== 'string' || !/^[0-9a-f]{7,40}$/i.test(r.reviewedAgainst.trim()))) {
278
+ errors.push('reviewedAgainst 须为 7-40 位 hex sha(git rev-parse HEAD 输出,照抄任务书标注)')
279
+ }
171
280
  if (!Array.isArray(r.findings)) errors.push('findings 必须为数组')
172
281
  else {
173
282
  r.findings.forEach((f, i) => {
@@ -181,4 +290,25 @@ export function validateReviewJson(path) {
181
290
  return { ok: true, errors: [], review: r }
182
291
  }
183
292
 
184
- export default { classifyReviewNeed, renderReviewerTaskbook, validateReviewJson, sampleBucket }
293
+ /**
294
+ * review.json 是否锚定到指定 HEAD(漂移重冻结隔离前的保留判定,
295
+ * 2026-10-06-review-anchor-and-negation 实测竞态:复审对着当前 HEAD 做的 PASS 结论
296
+ * 落盘 26 秒后被「重冻结时刻在场=过期」口径误隔离)。reviewedAgainst 缺省/读失败/
297
+ * 格式非法一律 false(fail-safe:宁可多评一次,不放行无锚证据)。
298
+ * 匹配口径:双向前缀(reviewedAgainst 允许短 sha 形态,与完整 head 任一方向前缀命中即锚定)。
299
+ * @param {string} reviewPath - review.json 路径
300
+ * @param {string} head - 当前完整 HEAD sha
301
+ * @returns {boolean}
302
+ */
303
+ export function reviewAnchoredToHead(reviewPath, head) {
304
+ const h = String(head || '').trim().toLowerCase()
305
+ if (!h) return false
306
+ try {
307
+ const r = JSON.parse(readFileSync(reviewPath, 'utf8'))
308
+ const ra = typeof r?.reviewedAgainst === 'string' ? r.reviewedAgainst.trim().toLowerCase() : ''
309
+ if (!/^[0-9a-f]{7,40}$/.test(ra)) return false
310
+ return h === ra || h.startsWith(ra) || ra.startsWith(h)
311
+ } catch { return false }
312
+ }
313
+
314
+ export default { classifyReviewNeed, renderReviewerTaskbook, validateReviewJson, reviewAnchoredToHead, sampleBucket }