sillyspec 3.33.0 → 3.33.2

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 (72) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +18 -10
  2. package/.github/workflows/ci.yml +11 -3
  3. package/AGENTS.md +3 -3
  4. package/README.md +2 -2
  5. package/assets/command-cards/flow.md +9 -5
  6. package/package.json +1 -1
  7. package/src/archive-delta.js +4 -3
  8. package/src/change-delete.js +6 -5
  9. package/src/contract-matrix.js +2 -1
  10. package/src/cross-placement.js +5 -1
  11. package/src/cross-repo-reconcile.js +4 -1
  12. package/src/db.js +8 -2
  13. package/src/decision-distill.js +22 -8
  14. package/src/decision-values.js +34 -0
  15. package/src/decisions-io.js +5 -2
  16. package/src/deleted-ledger.js +2 -1
  17. package/src/doctor-diagnostics.js +8 -3
  18. package/src/flow-draft.js +17 -10
  19. package/src/flow-parity.js +93 -5
  20. package/src/flow-review-pack.js +183 -0
  21. package/src/flow-review.js +45 -3
  22. package/src/flow.js +365 -109
  23. package/src/friction-ledger.js +2 -1
  24. package/src/friction-tally.js +8 -4
  25. package/src/handoff.js +2 -1
  26. package/src/hooks/worktree-guard.js +5 -5
  27. package/src/index.js +62 -21
  28. package/src/init.js +7 -30
  29. package/src/knowledge-hits.js +2 -1
  30. package/src/knowledge-vector.js +2 -1
  31. package/src/progress/consistency-doctor.js +5 -2
  32. package/src/progress/stage-machine.js +6 -2
  33. package/src/progress.js +19 -8
  34. package/src/quicklog.js +194 -9
  35. package/src/review-dispatch.js +4 -3
  36. package/src/review-tier.js +2 -1
  37. package/src/route-hindsight.js +4 -3
  38. package/src/run/bg-sync.js +10 -3
  39. package/src/run/command.js +14 -12
  40. package/src/run/complete-handlers.js +40 -37
  41. package/src/run/complete.js +12 -8
  42. package/src/run/gate-snapshot-ledger.js +2 -1
  43. package/src/run/gate-snapshot.js +102 -23
  44. package/src/run/gates.js +32 -8
  45. package/src/run/prompt.js +6 -5
  46. package/src/run/scan-profile.js +2 -1
  47. package/src/run/shared.js +109 -5
  48. package/src/run/stage.js +5 -4
  49. package/src/run/test-ledger.js +2 -1
  50. package/src/run/verify-quality-scan.js +11 -5
  51. package/src/runtime-paths.js +189 -0
  52. package/src/scan-refresh.js +4 -3
  53. package/src/scope-audit.js +13 -7
  54. package/src/sentinel-assertions.js +37 -0
  55. package/src/spec-sync.js +38 -22
  56. package/src/stage-contract.js +24 -2
  57. package/src/stage-review.js +3 -2
  58. package/src/stage-templates.js +3 -2
  59. package/src/stages/brainstorm.js +6 -5
  60. package/src/sync-noise.js +3 -2
  61. package/src/sync.js +71 -15
  62. package/src/task-done.js +2 -1
  63. package/src/task-review.js +7 -5
  64. package/src/task-tick.js +45 -1
  65. package/src/validate-artifacts.js +2 -1
  66. package/src/verify-draft.js +2 -1
  67. package/src/verify-postcheck.js +11 -10
  68. package/src/verify-probes.js +157 -52
  69. package/src/watcher.js +15 -9
  70. package/src/worktree-apply.js +2 -1
  71. package/src/write-audit.js +2 -1
  72. package/templates/agents-instruction.md +3 -3
@@ -267,13 +267,24 @@ export function renderThinDocsDebt({ specBase, projectRoot, changedFiles } = {})
267
267
  */
268
268
  export function renderVerifyReceipt({ change, baseline, head, gateSummary, review, traceCount, patchMeta, generatedAt }) {
269
269
  const sha = patchMeta && patchMeta.patchSha256 ? patchMeta.patchSha256.slice(0, 12) : null
270
+ // 空面跨仓冻结(2026-10-19-thin-empty-face-cross-repo-freeze):JSON-only 留档没有顶级
271
+ // patchSha256,但 scopeAudit.repos[] 内有跨仓 patch 正文——回执须如实报「跨仓 N 仓」,
272
+ // 不能写「无冻结件」(否则平台/归档读者把成功冻结误判成缺件)。
273
+ const repoFrozen = patchMeta && patchMeta.scopeAudit && Array.isArray(patchMeta.scopeAudit.repos)
274
+ ? patchMeta.scopeAudit.repos.filter((r) => r && typeof r.patch === 'string' && r.patch.length > 0)
275
+ : []
276
+ const freezeLine = sha
277
+ ? `change.patch(sha256 ${sha}…)`
278
+ : repoFrozen.length > 0
279
+ ? `跨仓 ${repoFrozen.length} 仓(diff 见 change-patch.json scopeAudit.repos[];主仓面为空)`
280
+ : '(无冻结件)'
270
281
  const reviewLine = review
271
282
  ? review.verdict === 'exempt'
272
283
  ? '豁免(低风险证据齐全' + (review.sampled ? '' : '') + ')'
273
284
  : `${review.verdict}(reviewer 见 review.json${Number.isFinite(review.findingsP1) ? `,P1 ${review.findingsP1}` : ''})`
274
285
  : '—'
275
286
  const headNote = head && baseline && head === baseline
276
- ? '(等于基线——交付代码尚未提交,冻结面以 change.patch 实际内容为准)'
287
+ ? '(等于基线——交付代码尚未提交,冻结面以交付留档实际内容为准:change.patch 或 change-patch.json repos[])'
277
288
  : ''
278
289
  return [
279
290
  `---`,
@@ -287,7 +298,7 @@ export function renderVerifyReceipt({ change, baseline, head, gateSummary, revie
287
298
  `- **实测面**:${gateSummary || '—'}`,
288
299
  `- **独立评审**:${reviewLine}`,
289
300
  `- **测试绑定**:${traceCount > 0 ? `${traceCount} 行(test-trace.json,已随发号提升)` : '0(无锚行)'}`,
290
- `- **交付冻结**:${sha ? `change.patch(sha256 ${sha}…)` : '(无冻结件)'}`,
301
+ `- **交付冻结**:${freezeLine}`,
291
302
  `- **生成**:${generatedAt}(机器合成,勿手改;明细见 flow-telemetry.jsonl / change-patch.json)`,
292
303
  ``,
293
304
  ].join('\n')
@@ -368,9 +379,23 @@ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision,
368
379
  * 反解,baseline 自 flow-state.yaml 读取;不可得(非 git 仓/无 flow-state/git 失败/归档后
369
380
  * 目录已移走)= 行为与旧版完全一致。保守:被无后缀裸提交触碰过的文件不剔(fail-closed)。
370
381
  */
382
+ // 工具治理模板(2026-10-19-freeze-filter-tool-scaffold):各 agent 工具的
383
+ // skills/commands/settings 注入面——不是本项目的交付语义,冻结面出现只会刷屏
384
+ // 真实交付(消防工作区 4e9eb05b 实证:61 文件里 50 个是 .claude/.codex 模板)。
385
+ // AGENTS.md / INSTRUCTIONS.md 是项目级指令(可能是变更的真实交付),保留。
386
+ const TOOL_GOVERNANCE_DIRS = ['.claude/', '.codex/', '.gemini/', '.opencode/', '.zcode/', '.cursor/']
387
+ const TOOL_GOVERNANCE_FILES = new Set(['CLAUDE.md'])
388
+
389
+ export function isToolGovernancePath(f) {
390
+ const p = String(f || '').replace(/\\/g, '/')
391
+ if (!p) return false
392
+ return TOOL_GOVERNANCE_FILES.has(p) || TOOL_GOVERNANCE_DIRS.some((d) => p.startsWith(d))
393
+ }
394
+
371
395
  export function filterCommittedFace(committedRaw, ownPrefix, opts = {}) {
372
396
  const base = committedRaw
373
397
  .map((f) => String(f).replace(/\\/g, '/'))
398
+ .filter((p) => !isToolGovernancePath(p))
374
399
  .filter((p) => !p.startsWith('.sillyspec/') || p.startsWith('.sillyspec/docs/'))
375
400
  try {
376
401
  const cwd = opts.cwd || process.cwd()
@@ -409,6 +434,63 @@ export function filterCommittedFace(committedRaw, ownPrefix, opts = {}) {
409
434
  * @param {{ cwd: string, change: string, freezeHead: string|null }} opts
410
435
  * @returns {{ drifted: boolean, ownCommits: string[], head: string|null, ownFiles: string[], governanceOnly: boolean, touchedPromiseFace: boolean }} ownCommits=本变更后缀提交的 subject 列表(截 80 字)
411
436
  */
437
+ /**
438
+ * 冻结件完整性探针(2026-10-19-thin-empty-face-cross-repo-freeze):空面跨仓冻结走
439
+ * JSON-only 留档(主仓面为空,`scopeAudit.repos[]` 承载跨仓 patch 正文,无 change.patch)。
440
+ * 存在性复查不能把「无 change.patch」一律判缺件——JSON 在场且 repos 内有 patch 正文即完整。
441
+ * 坏 JSON/读失败按缺件处理(重冻结兜底,fail-safe 不新造静默跳过面)。
442
+ * @returns {{ hasJson: boolean, hasText: boolean, repoPatchCount: number, complete: boolean }}
443
+ */
444
+ export function inspectFreezeArtifacts(changeDir) {
445
+ const patchJsonPath = join(changeDir, 'change-patch.json')
446
+ const patchTextPath = join(changeDir, 'change.patch')
447
+ const hasJson = existsSync(patchJsonPath)
448
+ const hasText = existsSync(patchTextPath)
449
+ let repoPatchCount = 0
450
+ let jsonOk = false
451
+ if (hasJson) {
452
+ try {
453
+ const meta = JSON.parse(readFileSync(patchJsonPath, 'utf8'))
454
+ jsonOk = true
455
+ const repos = meta && meta.scopeAudit && meta.scopeAudit.repos
456
+ if (Array.isArray(repos)) {
457
+ repoPatchCount = repos.filter((r) => r && typeof r.patch === 'string' && r.patch.length > 0).length
458
+ }
459
+ } catch { /* 坏 JSON → 按缺件处理(重冻结兜底) */ }
460
+ }
461
+ // jsonOk 与 hasText 必须同时成立:change.patch 在场但 change-patch.json 损坏时,
462
+ // 冻结元数据已不可信(审计面缺锚),不能因正文文件在就判完整。
463
+ return { hasJson, hasText, jsonOk, repoPatchCount, complete: jsonOk && (hasText || repoPatchCount > 0) }
464
+ }
465
+
466
+ /**
467
+ * 冻结缺口标签(存在性复查的调用点共用逻辑,纯函数便于直测):
468
+ * 完整 → [];否则给出可读缺件清单(坏 JSON 与真缺件区分,防止「正文在就判完整」盲区)。
469
+ * @param {{ hasJson?: boolean, hasText?: boolean, jsonOk?: boolean, repoPatchCount?: number, complete?: boolean }} state
470
+ * @returns {string[]}
471
+ */
472
+ export function freezeGapLabels(state) {
473
+ const s = state && typeof state === 'object' ? state : {}
474
+ if (s.complete === true) return []
475
+ const gaps = []
476
+ if (!s.hasJson) gaps.push('change-patch.json')
477
+ else if (!s.jsonOk) gaps.push('change-patch.json(损坏)')
478
+ if (!s.hasText && !(Number(s.repoPatchCount) > 0)) gaps.push('change.patch')
479
+ if (gaps.length === 0) gaps.push('冻结件不完整')
480
+ return gaps
481
+ }
482
+
483
+ /**
484
+ * 冻结缺口标签(目录级入口,flow done 存在性复查的唯一调用面):
485
+ * 检查(inspect)与标签(freezeGapLabels)一次封装——调用点直接用本函数,
486
+ * 直测本函数即覆盖调用点接线(防「探针修了调用点没接」的回归)。
487
+ * @param {string} changeDir
488
+ * @returns {string[]}
489
+ */
490
+ export function missingFreezeLabels(changeDir) {
491
+ return freezeGapLabels(inspectFreezeArtifacts(changeDir))
492
+ }
493
+
412
494
  export function detectPatchDrift({ cwd, change, freezeHead }) {
413
495
  try {
414
496
  if (!freezeHead) return { drifted: false, ownCommits: [], head: null, ownFiles: [], governanceOnly: false, touchedPromiseFace: false }
@@ -442,6 +524,7 @@ export function detectPatchDrift({ cwd, change, freezeHead }) {
442
524
  */
443
525
  export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive }) {
444
526
  const deliverable = (f) => !String(f).replace(/\\/g, '/').startsWith('.sillyspec/')
527
+ && !isToolGovernancePath(f)
445
528
  let dirty = []
446
529
  try {
447
530
  // -uall(2026-10-08-thin-done-dirty-gate-and-paren-attribution):全新目录会折叠成
@@ -519,6 +602,7 @@ export function scanOrphanFace(cwd, { baseline } = {}) {
519
602
  .split('\n').map((s) => s.trim()).filter(Boolean)
520
603
  const isAncestor = (sha, of) => git(['merge-base', '--is-ancestor', sha, of], {}) !== null
521
604
  const deliverable = (f) => !String(f).replace(/\\/g, '/').startsWith('.sillyspec/')
605
+ && !isToolGovernancePath(f)
522
606
  const orphanCommits = []
523
607
  const orphanFiles = new Set()
524
608
  for (const h of [...new Set(reflogHashes)]) {
@@ -656,13 +740,17 @@ export function projectTraceFaceRows(rows) {
656
740
  * @param {object} meta change-patch.json 顶级增量键(note/moduleScope 等,展开在 totals 后)
657
741
  * @returns {{ patchStatus: 'ok'|'failed', patchSha256: string|null }}
658
742
  */
659
- export function writeCloseTraceArtifacts({ changeDir, change, baseline, head, files, metaTotals, patchText, savedAt, snapObj, meta = {} }) {
743
+ export function writeCloseTraceArtifacts({ changeDir, change, baseline, head, files, metaTotals, patchText, savedAt, snapObj, meta = {}, patchStatus = null }) {
660
744
  const fileList = Array.isArray(files) ? files : []
661
745
  const patchOk = typeof patchText === 'string' && patchText.length > 0
662
746
  const patchBody = patchOk ? (patchText.endsWith('\n') ? patchText : patchText + '\n') : null
663
747
  const patchSha256 = patchBody
664
748
  ? createHash('sha256').update(patchBody.replace(/\r\n/g, '\n'), 'utf8').digest('hex')
665
749
  : null
750
+ // 显式 patchStatus(2026-10-19-thin-empty-face-cross-repo-freeze):空面跨仓冻结
751
+ // 没有主仓 patchText,但冻结本身成功——调用方传 'ok' 表达「冻结成功,无主仓 patch」,
752
+ // 不能复用 patchText 判空落 'failed'(平台/归档读者会把成功冻结误判为采集失败)。
753
+ const status = patchStatus === 'ok' || patchStatus === 'failed' ? patchStatus : (patchOk ? 'ok' : 'failed')
666
754
  if (patchOk) writeFileSync(join(changeDir, 'change.patch'), patchBody)
667
755
  writeFileSync(join(changeDir, 'change-patch.json'), JSON.stringify({
668
756
  change,
@@ -676,9 +764,9 @@ export function writeCloseTraceArtifacts({ changeDir, change, baseline, head, fi
676
764
  ...meta,
677
765
  scopeAudit: { ...snapObj },
678
766
  ...(patchSha256 ? { patchSha256 } : {}),
679
- patchStatus: patchOk ? 'ok' : 'failed',
767
+ patchStatus: status,
680
768
  }, null, 2) + '\n')
681
- return { patchStatus: patchOk ? 'ok' : 'failed', patchSha256 }
769
+ return { patchStatus: status, patchSha256 }
682
770
  }
683
771
 
684
772
  /**
@@ -0,0 +1,183 @@
1
+ /**
2
+ * flow-review-pack.js — 轻量评审任务书预消化材料包(2026-10-19-thin-review-material-pack)。
3
+ *
4
+ * 病根:轻量评审任务书把原始件(requirements/design/change.patch)原样丢给零上下文评审员,
5
+ * 定位类通读 + 大 patch 整吞 prefill 是主要成本(冻结件实测最大 365KB)。完整流程五阶段
6
+ * 2026-09-19 已为同一病根(评审 ~200x 信息放大)建过材料包(review-material-pack.js),
7
+ * 本模块把同款机制移植到轻量道,三条护栏(design 记录):
8
+ * ① 纯机械抽取零模型摘要——全部产出来自确定性行/正则解析,防「自述换皮」破坏「不信自述」;
9
+ * ② 包=基准面非禁读面——段尾印基准面语义行(照抄完整流程 D-004:包外定向查证须列明、
10
+ * 包不足写 cannot_verify);
11
+ * ③ 摘要从冻结件本身算——change.patch 文本直解,不走活 git(review-material-pack 的
12
+ * extractDiffSummary 走活 git 解 base 那条路不能搬:防「包说的」与评审员手里冻结面漂移)。
13
+ */
14
+ import { readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ /** 材料包整体体积帽(对齐 review-material-pack.js PACK_MAX_CHARS 先例——包不能成为新的全量面)。 */
18
+ export const PACK_MAX_CHARS = 12000
19
+
20
+ /** 段标题与段尾基准面语义行(护栏②:照抄完整流程 D-004 语义——包=基准面非禁读面)。 */
21
+ const PACK_TITLE = '【预消化材料包(CLI 机械抽取——定位索引非证据)】'
22
+ const PACK_BASELINE = ' (基准面:包=定位索引非证据;包外定向查证须列明;包不足以作答写 cannot_verify)'
23
+
24
+ /** FR 标题行(h3):### FR-NN: 标题——锚形态与 requirements 机器起草模板一致。 */
25
+ const FR_HEADING_RE = /^###\s+(FR-\d+)\s*[::]?\s*(.*)$/
26
+
27
+ /** 测试绑定节内行:FR-NN: 绑定内容。 */
28
+ const FR_BINDING_LINE_RE = /^(FR-\d+)\s*[::]\s*(.+)$/
29
+
30
+ /** diff 文件头:diff --git a/<旧> b/<新>——路径取 b/ 侧(rename 时即新名)。 */
31
+ const DIFF_GIT_RE = /^diff --git a\/(.+?) b\/(.+)$/
32
+
33
+ /** hunk 头(计数用,不解析行号——摘要只要 hunk 数)。 */
34
+ const HUNK_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/
35
+
36
+ /**
37
+ * FR↔测试绑定表解析(纯函数):h3 FR 标题按出现序成行,「## 测试绑定」节内 `FR-NN: …`
38
+ * 行按 id 归并。requirements 缺 FR 标题 → 空数组(调用方按该子源零产出处理)。
39
+ * @returns {{ fr: string, title: string, binding: string }[]}
40
+ */
41
+ export function parseFrBindingRows(requirementsText) {
42
+ const lines = String(requirementsText || '').replace(/\r\n/g, '\n').split('\n')
43
+ const rows = []
44
+ for (const line of lines) {
45
+ const m = line.match(FR_HEADING_RE)
46
+ if (m) rows.push({ fr: m[1], title: String(m[2] || '').trim(), binding: '' })
47
+ }
48
+ if (rows.length === 0) return rows
49
+ let inBinding = false
50
+ for (const line of lines) {
51
+ if (/^##\s/.test(line)) { inBinding = /^##\s*测试绑定/.test(line); continue }
52
+ if (!inBinding) continue
53
+ const m = line.match(FR_BINDING_LINE_RE)
54
+ if (m) {
55
+ const row = rows.find((r) => r.fr === m[1])
56
+ if (row) row.binding = String(m[2] || '').trim()
57
+ }
58
+ }
59
+ return rows
60
+ }
61
+
62
+ /**
63
+ * 冻结 patch 文件级摘要(纯函数):按 `diff --git` 头分文件,统计每文件 +增/−删行数
64
+ * (排除 +++/--- 文件名行)与 hunk 数;rename 取 b/ 侧新路径;Binary 行零计数容错。
65
+ * 无文件头的杂文本 → 空数组(不抛错——垃圾进空出,调用方按零产出处理)。
66
+ * @returns {{ path: string, plus: number, minus: number, hunks: number }[]}
67
+ */
68
+ export function parseFrozenPatchSummary(patchText) {
69
+ const text = String(patchText || '')
70
+ if (!text.trim()) return []
71
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
72
+ const files = []
73
+ let cur = null
74
+ for (const line of lines) {
75
+ const dg = line.match(DIFF_GIT_RE)
76
+ if (dg) {
77
+ if (cur) files.push(cur)
78
+ cur = { path: dg[2].trim(), plus: 0, minus: 0, hunks: 0 }
79
+ continue
80
+ }
81
+ if (!cur) continue
82
+ if (HUNK_RE.test(line)) { cur.hunks += 1; continue }
83
+ if (line.startsWith('+++') || line.startsWith('---')) continue
84
+ if (line.startsWith('+')) cur.plus += 1
85
+ else if (line.startsWith('-')) cur.minus += 1
86
+ }
87
+ if (cur) files.push(cur)
88
+ return files
89
+ }
90
+
91
+ /**
92
+ * 归属切分归一(fail-soft 入参):非法/缺席 → null(调用方零注入该子段);数组字段缺省按空。
93
+ * 文件路径统一正斜杠归一(Windows/WSL 混用兼容——评审对象路径口径与冻结件一致)。
94
+ */
95
+ function normalizeAttribution(attribution) {
96
+ if (!attribution || typeof attribution !== 'object' || Array.isArray(attribution)) return null
97
+ const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/^\.\//, '')
98
+ const asList = (v) => (Array.isArray(v) ? v.map(norm).filter(Boolean) : [])
99
+ const foreign = Array.isArray(attribution.foreign)
100
+ ? attribution.foreign
101
+ .filter((f) => f && typeof f === 'object' && f.file)
102
+ .map((f) => ({ file: norm(f.file), owners: Array.isArray(f.owners) ? f.owners.map((o) => String(o)).filter(Boolean) : [] }))
103
+ : []
104
+ return { declaredIn: asList(attribution.declaredIn), declaredOut: asList(attribution.declaredOut), foreign }
105
+ }
106
+
107
+ /** 归属切分三段渲染(2026-10-19-thin-review-dispatch-verbatim):零计数照常渲染(不静默隐藏)。 */
108
+ function attributionLines(a) {
109
+ const join = (xs) => (xs.length > 0 ? xs.join('、') : '(无)')
110
+ const foreignLines = a.foreign.map((f) => `${f.file}←${f.owners.length > 0 ? f.owners.join('/') : '未知变更'}`)
111
+ return [
112
+ ' 冻结面归属切分(CLI 机械核算——定位索引非证据):',
113
+ ` - 声明内(design 变更清单 ∩ 冻结面,${a.declaredIn.length} 文件):${join(a.declaredIn)}`,
114
+ ` - 声明外(冻结面−声明清单,${a.declaredOut.length} 文件——共享仓并行混入/生成物候选,抽查即可勿逐文件 git 考古):${join(a.declaredOut)}`,
115
+ ` - 他侧归因(提交/声明归属其他变更,${a.foreign.length} 文件):${join(foreignLines)}`,
116
+ ]
117
+ }
118
+
119
+ /**
120
+ * 归属切分集合运算(纯函数——flow done review 子步装配用):声明内 = design 变更清单 ∩
121
+ * 冻结面(他侧归因文件除外,归属歧义按他侧披露处理);声明外 = 冻结面 − 声明清单 − 他侧
122
+ * 归因;他侧归因原样归一化(owner 去重保序)。路径统一正斜杠 + 去 `./` 前缀 + 冻结面去重
123
+ * (Windows/WSL 混用与 patch 同文件多段兼容)。纯集合运算零 IO——git/解析失败由调用方
124
+ * fail-soft(源头失败 → 不调用本函数)。
125
+ * @param {{ declaredFiles?: string[], freezeFiles?: string[], foreign?: Array<{file: string, owners?: string[]}> }} args
126
+ * @returns {{ declaredIn: string[], declaredOut: string[], foreign: Array<{file: string, owners: string[]}> }}
127
+ */
128
+ export function buildFreezeAttribution({ declaredFiles, freezeFiles, foreign }) {
129
+ const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/^\.\//, '')
130
+ const declared = new Set((Array.isArray(declaredFiles) ? declaredFiles : []).map(norm).filter(Boolean))
131
+ const frozen = [...new Set((Array.isArray(freezeFiles) ? freezeFiles : []).map(norm).filter(Boolean))]
132
+ const foreignList = (Array.isArray(foreign) ? foreign : [])
133
+ .filter((f) => f && typeof f === 'object' && f.file)
134
+ .map((f) => ({ file: norm(f.file), owners: [...new Set((Array.isArray(f.owners) ? f.owners : []).map(String).filter(Boolean))] }))
135
+ const foreignSet = new Set(foreignList.map((f) => f.file))
136
+ return {
137
+ declaredIn: frozen.filter((f) => declared.has(f) && !foreignSet.has(f)),
138
+ declaredOut: frozen.filter((f) => !declared.has(f) && !foreignSet.has(f)),
139
+ foreign: foreignList,
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 装配材料包注入行(唯一组包入口):FR 表、patch 摘要、归属切分三子源各自可得各自产出、
145
+ * 全缺返回 null(调用方零注入)。体积帽行级截断(不切半行),截断注记置于基准面语义行之前——
146
+ * 语义行恒为末行;标题+正文+注记+语义行总长恒 ≤ PACK_MAX_CHARS(截断预留注记预算)。
147
+ * requirements.md 读失败(缺件)→ FR 子段零产出;attribution 缺席/非法 → 归属子段零产出
148
+ * (fail-soft 不抛穿,其余子段不受影响)。
149
+ * @param {{ changeDir: string, patchText: string|null, attribution?: object|null }} args
150
+ * @returns {{ lines: string[], truncated: boolean } | null}
151
+ */
152
+ export function assembleReviewPack({ changeDir, patchText, attribution = null }) {
153
+ let frRows = []
154
+ try {
155
+ frRows = parseFrBindingRows(readFileSync(join(changeDir, 'requirements.md'), 'utf8'))
156
+ } catch { /* 缺件 → FR 子段零产出 */ }
157
+ const summary = parseFrozenPatchSummary(patchText)
158
+ if (frRows.length === 0 && summary.length === 0) return null
159
+ const body = []
160
+ if (frRows.length > 0) {
161
+ body.push(` FR↔测试绑定表(requirements.md 机械解析,${frRows.length} 条):`)
162
+ for (const r of frRows) body.push(` - ${r.fr}: ${r.title}${r.binding ? ` | 绑定: ${r.binding}` : ' | 绑定: (无绑定行)'}`)
163
+ }
164
+ if (summary.length > 0) {
165
+ body.push(` 冻结 patch 文件级摘要(change.patch 机械解析,${summary.length} 文件):`)
166
+ for (const f of summary) body.push(` - ${f.path}(+${f.plus}/-${f.minus},${f.hunks} hunk)`)
167
+ }
168
+ const attr = normalizeAttribution(attribution)
169
+ if (attr) body.push(...attributionLines(attr))
170
+ const note = ` …(材料包超 ${PACK_MAX_CHARS} 字符截断——需全文时定向查证并列明)`
171
+ let budget = PACK_MAX_CHARS - PACK_TITLE.length - PACK_BASELINE.length - note.length
172
+ const lines = [PACK_TITLE]
173
+ let truncated = false
174
+ for (const l of body) {
175
+ const w = l.length + 1
176
+ if (w > budget) { truncated = true; break }
177
+ lines.push(l)
178
+ budget -= w
179
+ }
180
+ if (truncated) lines.push(note)
181
+ lines.push(PACK_BASELINE)
182
+ return { lines, truncated }
183
+ }
@@ -86,6 +86,22 @@ function readSlotAnswer(text, markerRe) {
86
86
  return buf.join('\n').trim()
87
87
  }
88
88
 
89
+ /**
90
+ * design 四节已答判定(v1 AGENT 槽 / v2 节作答双格式——status 与 task tick 共享单一源,
91
+ * 2026-10-19-approve-gate-tick-nudge FR-03 上移;判据与 flow status 原内联逻辑逐字一致:
92
+ * v1 = 槽标记行后紧跟非空行的槽数 ≥3;v2 = DESIGN_QUESTIONS 四节实质作答(问题行剥离)≥3)。
93
+ */
94
+ export function designSectionsFilled(dText) {
95
+ const text = String(dText || '').replace(/\r\n/g, '\n')
96
+ if (!text) return false
97
+ if (/<!--AGENT:槽\d+/.test(text)) {
98
+ return Array.from(text.matchAll(/<!--AGENT:槽\d+[^\n]*-->\n(\S)/g)).length >= 3
99
+ }
100
+ try {
101
+ return DESIGN_QUESTIONS_FALLBACK.sections.filter((s) => readV2SectionAnswer(text, s.heading, DESIGN_QUESTIONS_FALLBACK)).length >= 3
102
+ } catch { return false }
103
+ }
104
+
89
105
  /**
90
106
  * 读 v2 纯 markdown design 节作答(2026-10-04-thin-docs-v2):节标题行到下一节标题之间的
91
107
  * 非空行,剥离四问原文行(DESIGN_QUESTIONS 单一源同源引入——独立字符串会被评审实证为
@@ -204,7 +220,7 @@ export function classifyReviewNeed({ changeDir, input = '', patchText = null, ed
204
220
  * 印出完整 sha 并要求 review.json 的 reviewedAgainst 照抄;漂移重冻结据此区分「对着旧冻结面的
205
221
  * 过期评审」与「对着当前 HEAD 的刚完成评审」(实测竞态:复审 PASS 落盘 26 秒后被误隔离)。
206
222
  */
207
- export function renderReviewerTaskbook({ change, changeDir, head = null, probeLines = null }) {
223
+ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLines = null, packLines = null }) {
208
224
  const headFull = String(head || '').trim()
209
225
  // 前轮 findings 注入(2026-10-07-wave-auto-adopt-review-dedup task-02):复审场景(前轮 FAIL/
210
226
  // 评审隔离重评)下任务书带上前轮清单——已报项只验修复与回归,重点找新增问题,勿整轮重报
@@ -234,12 +250,29 @@ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLi
234
250
  ].join('\n')
235
251
  }
236
252
  } catch { /* 无 review.json / 损坏 → 首评形态,零注入 */ }
253
+ // 冻结面形态(2026-10-19-thin-empty-face-cross-repo-freeze):JSON-only 跨仓冻结没有
254
+ // change.patch,交付 diff 在 change-patch.json 的 scopeAudit.repos[]——任务书材料与
255
+ // 对象表述必须指向真实在場的冻结件,否则评审员按缺失文件判 patch 失败。
256
+ const hasMainPatchFile = existsSync(join(changeDir, 'change.patch'))
257
+ let repoPatchKeys = []
258
+ try {
259
+ const meta = JSON.parse(readFileSync(join(changeDir, 'change-patch.json'), 'utf8'))
260
+ const repos = meta && meta.scopeAudit && Array.isArray(meta.scopeAudit.repos) ? meta.scopeAudit.repos : []
261
+ repoPatchKeys = repos
262
+ .filter((r) => r && typeof r.patch === 'string' && r.patch.length > 0)
263
+ .map((r) => String(r.key || '?'))
264
+ } catch { /* 无/坏 JSON → 单仓形态 */ }
265
+ const freezeLabel = hasMainPatchFile ? 'change.patch 冻结面' : repoPatchKeys.length > 0 ? 'change-patch.json 跨仓冻结面' : '冻结面'
237
266
  const taskbook = [
238
267
  `⚖️ 独立评审任务书 — ${change}`,
239
268
  `══════════════════════════════════════`,
269
+ // 派发纪律(2026-10-19-thin-review-dispatch-verbatim):对齐厚道先例(stage-review.js:803 /
270
+ // index.js:2576)——实测主会话派发时手写缩编 prompt 会把材料包整段丢掉(4 次派发零 pack);
271
+ // 检查单/材料包生产权在 CLI,原样复制是纪律不是建议。无条件在场(非可选注入)。
272
+ `(派发纪律:本任务书原样复制给干净上下文子代理——检查单/材料包生产权在 CLI,勿自行改写、增删或缩编)`,
240
273
  `【角色】你是独立评审员(干净上下文,未参与实现)——拿承诺对代码,不信自述,零误报标准。`,
241
274
  ...(headFull ? [
242
- `【评审对象】工作区实态 + git HEAD(${headFull})——change.patch 冻结面若落后于该 HEAD,以`,
275
+ `【评审对象】工作区实态 + git HEAD(${headFull})——${freezeLabel}若落后于该 HEAD,以`,
243
276
  ` 工作区实态与 git log/show 核对为准;review.json 的 reviewedAgainst 字段照抄上面的完整 sha。`,
244
277
  ] : [
245
278
  `【评审对象】工作区实态 + git HEAD——review.json 的 reviewedAgainst 字段填 git rev-parse HEAD`,
@@ -249,7 +282,16 @@ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLi
249
282
  `【材料(按需 Read,请求预算硬帽 12 次——超帽即止,包不足以作答写 cannot_verify)】`,
250
283
  ` - ${join(changeDir, 'requirements.md')}(FR 承诺 + 每条 FR 的测试绑定作答)`,
251
284
  ` - ${join(changeDir, 'design.md')}(设计承诺:做法/接口契约/盲维四问作答/风险)`,
252
- ` - ${join(changeDir, 'change.patch')}(交付 diff 冻结件——代码事实的唯一来源)`,
285
+ ...(hasMainPatchFile
286
+ ? [` - ${join(changeDir, 'change.patch')}(交付 diff 冻结件——代码事实的唯一来源)`]
287
+ : repoPatchKeys.length > 0
288
+ ? [` - ${join(changeDir, 'change-patch.json')}(跨仓交付 diff 冻结件——scopeAudit.repos[] 的 ${repoPatchKeys.join('、')} 仓 patch 正文;主仓面为空故无 change.patch,diff 在 JSON 内联)`]
289
+ : [` - ${join(changeDir, 'change.patch')}(若缺失按 patch 留档失败处理)`]),
290
+ // 预消化材料包(2026-10-19-thin-review-material-pack):CLI 机械抽取的 FR↔测试绑定表 +
291
+ // 冻结 patch 文件级摘要(flow-review-pack.js 装配,12000 帽行级截断)——砍零上下文评审员
292
+ // 的定位类读与大 patch 整吞 prefill;段自带基准面语义行(包=索引非证据);缺省/空数组
293
+ // 零注入——任务书与现状逐字一致(既有调用方/既有测试零感知)。
294
+ ...(Array.isArray(packLines) && packLines.length > 0 ? ['', ...packLines] : []),
253
295
  ...(priorFindingsMd ? priorFindingsMd.split('\n') : []),
254
296
  ``,
255
297
  `【检查单(逐条作答,P1=承诺被违反或有数据正确性风险)】`,