sillyspec 3.31.0 → 3.32.0

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 +62 -4
  19. package/src/flow-review.js +136 -6
  20. package/src/flow.js +729 -125
  21. package/src/foreign-declared.js +71 -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 +66 -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 +77 -11
  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 +9 -2
  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 +509 -37
  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
@@ -14,9 +14,11 @@
14
14
  import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
15
15
  import { join, relative } from 'node:path'
16
16
  import { execFileSync } from 'node:child_process'
17
- import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
17
+ import { splitOwnVsForeignDiffFiles, commitAttributionForChange, isForeignByCommit, parseChangeNamesFromSubject } from './foreign-declared.js'
18
18
  import { collectModuleMaps, normalizeMapPath } from './module-resolve.js'
19
19
  import { writeAtomicSync } from './fs-atomic.js'
20
+ import { readV2SectionAnswer } from './flow-review.js'
21
+ import { DESIGN_QUESTIONS } from './flow-draft.js'
20
22
 
21
23
  /**
22
24
  * 模块文档对账(advisory)+ 结构化落盘面(2026-09-27-thin-module-scope-persist)。
@@ -144,7 +146,13 @@ export function harvestSlot4Decision({ changeDir, change }) {
144
146
  if (inSlot && (/^<!--/.test(line) || /^#{1,6}\s/.test(line))) break
145
147
  if (inSlot) buf.push(line)
146
148
  }
147
- const answer = buf.join('\n').trim()
149
+ let answer = buf.join('\n').trim()
150
+ // v2 纯 markdown(2026-10-04-thin-docs-v2):无槽标记时改读「风险与死路」节正文(复用
151
+ // flow-review 的 v2 节读取——剥离问题原文行;单一源 DESIGN_QUESTIONS 防镜像漂移)
152
+ if (!inSlot) {
153
+ const risksSec = DESIGN_QUESTIONS.sections.find((s) => s.key === 'risks')
154
+ answer = readV2SectionAnswer(dText, risksSec.heading, DESIGN_QUESTIONS)
155
+ }
148
156
  if (!answer || /^不适用/.test(answer)) return { harvested: false, reason: '槽4 空/不适用' }
149
157
  const text = [
150
158
  '---',
@@ -177,11 +185,61 @@ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision,
177
185
  * 非 .sillyspec/ 全留 + 本变更目录(治理工件)+ .sillyspec/docs/ 交付文档(dogfood 模块卡
178
186
  * 是交付物——2026-09-27-gate-docs-cleanup 评审 P2 实证旧口径把已提交模块卡漏出审计 patch)。
179
187
  * 他侧 .sillyspec/changes/**(quicklog/knowledge WIP)仍滤除。反斜杠归一。
188
+ *
189
+ * 提交事实归属补位(2026-10-05-diff-commit-attribution):现状「已提交即本变更的」前提是
190
+ * 单会话——多会话共享仓 baseline..HEAD 混入他侧交付(评审 P2 实证:他会话 knowledge-stats
191
+ * hunk 冻进本变更审计件)。在现状过滤后剔除「窗口内全部提交均属他侧变更名」的交付文件。
192
+ * 与否决决策 sentinel-evidence-freeze⑤ 的分界:按提交 message 变更名(既成事实)切,不按
193
+ * 任何变更的声明清单(该决策禁的是陈旧声明的意图抢夺)。best-effort:变更名自 ownPrefix
194
+ * 反解,baseline 自 flow-state.yaml 读取;不可得(非 git 仓/无 flow-state/git 失败/归档后
195
+ * 目录已移走)= 行为与旧版完全一致。保守:被无后缀裸提交触碰过的文件不剔(fail-closed)。
180
196
  */
181
- export function filterCommittedFace(committedRaw, ownPrefix) {
182
- return committedRaw
197
+ export function filterCommittedFace(committedRaw, ownPrefix, opts = {}) {
198
+ const base = committedRaw
183
199
  .map((f) => String(f).replace(/\\/g, '/'))
184
200
  .filter((p) => !p.startsWith('.sillyspec/') || p.startsWith(ownPrefix) || p.startsWith('.sillyspec/docs/'))
201
+ try {
202
+ const cwd = opts.cwd || process.cwd()
203
+ const pm = /^\.sillyspec\/changes\/([^/]+)\/$/.exec(String(ownPrefix || ''))
204
+ if (!pm) return base
205
+ const specBase = opts.specBase || join(cwd, '.sillyspec')
206
+ const attr = commitAttributionForChange(cwd, specBase, pm[1], opts.baselineCommit || null)
207
+ if (!attr) return base
208
+ return base.filter((p) => {
209
+ if (p.startsWith('.sillyspec/')) return true // 治理面归属已由上方现状口径裁决
210
+ return !isForeignByCommit(attr.get(p), pm[1])
211
+ })
212
+ } catch { return base }
213
+ }
214
+
215
+ /**
216
+ * patch 冻结面漂移检测(2026-10-05-disposition-refreeze-drift,主清单 P1):处置重入审计
217
+ * 错位——评审发现处置涉及代码修改后重跑 flow done,patch 子步幂等跳过让 change.patch/
218
+ * review.json 停在处置前时点(2026-10-05-review-promise-negation 先例 905397ef;
219
+ * 2026-10-05-flowdone-lintfail-output 收编活体复现:评审 P1 处置提交后不带 --refreeze 重跑,
220
+ * 归档件缺处置面)。
221
+ *
222
+ * 判定:freezeHead(change-patch.json.head 冻结锚)..HEAD 窗口内存在「本变更名后缀提交」即
223
+ * 漂移——按提交 message 变更名归属(parseChangeNamesFromSubject,与 filterCommittedFace
224
+ * 同口径的既成事实切分,非声明抢文件)。仅他侧后缀/裸提交不触发(他侧面归属切分另有防线,
225
+ * 裸提交保守不动作)。检测自身失败按无漂移(退现状幂等跳过行为,fail-safe 不新造阻断面)。
226
+ * @param {{ cwd: string, change: string, freezeHead: string|null }} opts
227
+ * @returns {{ drifted: boolean, ownCommits: string[], head: string|null }} ownCommits=本变更后缀提交的 subject 列表(截 80 字)
228
+ */
229
+ export function detectPatchDrift({ cwd, change, freezeHead }) {
230
+ try {
231
+ if (!freezeHead) return { drifted: false, ownCommits: [], head: null }
232
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 30000, windowsHide: true }).trim()
233
+ if (!head || head === freezeHead) return { drifted: false, ownCommits: [], head }
234
+ const log = execFileSync('git', ['log', '--format=%x00%s', `${freezeHead}..HEAD`], { cwd, encoding: 'utf8', timeout: 30000, windowsHide: true })
235
+ const own = []
236
+ for (const block of String(log).split('\x00')) {
237
+ const subject = block.split('\n').map((l) => l.trim()).filter(Boolean)[0]
238
+ if (!subject) continue
239
+ if (parseChangeNamesFromSubject(subject).includes(change)) own.push(subject.slice(0, 80))
240
+ }
241
+ return { drifted: own.length > 0, ownCommits: own, head }
242
+ } catch { return { drifted: false, ownCommits: [], head: null } }
185
243
  }
186
244
 
187
245
  /**
@@ -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 }