@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.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 (167) hide show
  1. package/README.md +189 -7
  2. package/README.zh-CN.md +189 -7
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/FRONTEND-CO-CREATION.md +191 -0
  5. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  6. package/docs/HANDOFF-CRITERIA.md +92 -0
  7. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  8. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  9. package/docs/PROMO-PROMPT-3.0.md +100 -0
  10. package/docs/USER-GUIDE.en.md +58 -3
  11. package/docs/USER-GUIDE.zh-CN.md +59 -4
  12. package/docs/WHITEPAPER.md +207 -0
  13. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  14. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  15. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  16. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  17. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  18. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  19. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  20. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  21. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  22. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  23. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  24. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  25. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  26. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  27. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  28. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  29. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  30. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  31. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  32. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  33. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  34. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  35. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  36. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  37. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  38. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  39. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  40. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  41. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  42. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  43. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  44. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  45. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  46. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  47. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  48. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  49. package/docs/internal/KICKOFF-P0.md +254 -0
  50. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  51. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  52. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  53. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  54. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  55. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  56. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  57. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  58. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  59. package/docs/internal/PROGRESS-20260917.md +93 -0
  60. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  61. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  62. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  63. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  64. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  65. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  66. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  67. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  68. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  69. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  70. package/docs/internal/RESUME-20260918.md +171 -0
  71. package/docs/internal/RESUME-20260919.md +104 -0
  72. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  73. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  74. package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
  75. package/docs/internal/ROADMAP.md +106 -0
  76. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  77. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
  78. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  79. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  80. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  81. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  82. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  83. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  84. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  85. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  86. package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
  87. package/docs/internal/TODO-BACKLOG.md +263 -142
  88. package/docs/internal/TODO-GRAPH.html +715 -0
  89. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  90. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  91. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  92. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  93. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  94. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  95. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  96. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  97. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  98. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  99. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  100. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  101. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  102. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  103. package/docs/prompts/M8-3-enable-verify.md +49 -49
  104. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  105. package/lib/acceptance.js +71 -0
  106. package/lib/activation-host.js +153 -18
  107. package/lib/activation-inbox.js +25 -7
  108. package/lib/board-mode.js +30 -0
  109. package/lib/client.js +1758 -90
  110. package/lib/config-io.js +156 -0
  111. package/lib/context-bridge.js +5 -2
  112. package/lib/context-host.js +86 -15
  113. package/lib/degrade.js +385 -0
  114. package/lib/dsh-home.js +143 -0
  115. package/lib/engine-identity.js +149 -0
  116. package/lib/engine-switch.js +247 -0
  117. package/lib/episodic-store.js +63 -12
  118. package/lib/evidence-store.js +10 -3
  119. package/lib/fact-store.js +22 -3
  120. package/lib/fs-retry.js +46 -0
  121. package/lib/index-sync.js +13 -1
  122. package/lib/index.js +3446 -263
  123. package/lib/intent-clean-safe.js +258 -0
  124. package/lib/intent-clean.js +12 -16
  125. package/lib/l0-extract.js +478 -149
  126. package/lib/l0-index-sync.js +195 -0
  127. package/lib/l0-index.js +349 -239
  128. package/lib/ledger-criteria.js +142 -0
  129. package/lib/m4-corpus.js +8 -2
  130. package/lib/m7-index-sync-host.js +73 -5
  131. package/lib/m7-wire.js +3 -3
  132. package/lib/memory-anchor.js +56 -1
  133. package/lib/memory-envelope.js +257 -0
  134. package/lib/memory-hub.js +138 -13
  135. package/lib/memory-index.js +4 -2
  136. package/lib/memory-mutation.js +246 -0
  137. package/lib/memory-writer.js +204 -24
  138. package/lib/note-status-apply.js +118 -0
  139. package/lib/note-status.js +196 -0
  140. package/lib/procedure-observation.js +48 -0
  141. package/lib/procedure-store.js +118 -20
  142. package/lib/python-setup.js +1 -1
  143. package/lib/python-sidecar-client.js +29 -3
  144. package/lib/recall-fusion.js +83 -12
  145. package/lib/rerank-host.js +160 -0
  146. package/lib/rules-edit.js +159 -0
  147. package/lib/rules-layer.js +261 -0
  148. package/lib/semantic-decide.js +41 -8
  149. package/lib/semantic-js.js +66 -6
  150. package/lib/shadow-host.js +3 -5
  151. package/lib/shadow-retrieval.js +3 -3
  152. package/lib/skill-export-host.js +153 -0
  153. package/lib/skill-export.js +239 -0
  154. package/lib/state-commit.js +245 -0
  155. package/lib/storage-manage.js +6 -0
  156. package/lib/subagent-gc.js +4 -8
  157. package/lib/temporal-parse.js +191 -159
  158. package/lib/tier-layer-inject.js +650 -0
  159. package/lib/tier0-catalog.js +735 -0
  160. package/lib/water-window.js +263 -186
  161. package/lib/wb-contract.js +691 -0
  162. package/lib/wb-sidecar.js +890 -0
  163. package/lib/ws-overview-rank.js +2 -2
  164. package/package.json +1 -1
  165. package/python/m7_embedding_v1.py +5 -5
  166. package/python/worker_semantic_v1.py +17 -6
  167. package/python/worker_v1.py +38 -4
@@ -0,0 +1,691 @@
1
+ /**
2
+ * 白板格式适配器(wb_contract_v1)—— 白板线拥有「白板格式及其适配器」。
3
+ *
4
+ * 2026-09-14 建立(P0 最小适配边界)。**边界(总纲 §0.5 / ROUND3 §3.1 定案)**:
5
+ * - **3.0 主体**拥有「共同提交与保护入口」= `lib/memory-mutation.js:validateMutationBoundaryPre`
6
+ * —— 它**只接收规范化投影**(`beforeIds/afterIds/protectedRegions/changes`),**不自行解释图格式**。
7
+ * - **白板线**拥有「白板格式及其适配器」= **本模块** `parseWhiteboardPre`
8
+ * —— 格式只维护一份(`docs/internal/WB-FORMAT-CONVENTION.md` 是规格源)。
9
+ * 这样切的意义:写入门要保护**卡片集合**与**用户区**,而这两个概念由白板格式定义;
10
+ * 没有适配器就是"接口接上了但保护失效"。
11
+ *
12
+ * **规格源**:`docs/internal/WB-FORMAT-CONVENTION.md`
13
+ * §2 锚点契约(每个卡片/小节标题行下方紧跟 `<!-- memory:mem_<32hex> -->`);
14
+ * §5 人机分区(`<!-- model -->…<!-- /model -->` 与 `<!-- user -->…<!-- /user -->`,模型整篇重写
15
+ * 必须原样带回 user 段)。**B4 预授权默认值**:从选项 (c) 起步 = 每张卡片分「模型维护区 / 用户备注区」。
16
+ * **A8 预授权默认值**:id = `mem_` + sha256(workspaceKey + '\u0000' + 页面相对路径 + '\u0000' + 卡片标题) 前 32 位。
17
+ *
18
+ * **现状缺口(2026-09-14 实测,这是本模块存在的直接理由)**:规范 §5 **早已定义**人机分区,
19
+ * 但实际 `PLAN.md` **一个锚点、一个分区标记都没有** —— 规范已批准、代码从未实现。
20
+ * 这正是「白板被整篇覆盖成骨架」事故的根因。
21
+ *
22
+ * **本模块只做解析,不做判定**:它产出规范化投影(卡 id 集合、用户区集合、状态、标题),
23
+ * 由 `validateMutationBoundaryPre` 决定接受/拒绝。职责不混,是为了让"格式"只有一个真源。
24
+ *
25
+ * S9 合规:零 IO、纯函数、同输入同输出、无网络/无 LLM/无子进程/无 await。
26
+ * UTF-8 无 BOM。
27
+ */
28
+ import { createHash } from 'node:crypto'
29
+ // 账本四段判据复用既有解析器(`handoff-anchor.js` 是账本段结构的既有权威,不另写一份)
30
+ import { parseHandoffLedgerPre, HANDOFF_LEDGER_SECTION_WEIGHTS_V1 } from './handoff-anchor.js'
31
+
32
+ export const WB_CONTRACT_VERSION = 'wb_contract_v1'
33
+
34
+ /** 锚点正则(与 `l0-extract.js` 的 `MEM_ANCHOR_RE` 完全一致;契约 §2 要求"完全一致")。 */
35
+ export const WB_ANCHOR_RE_V1 = /^<!--\s*memory:(mem_[0-9a-f]{32})\s*-->$/
36
+
37
+ /** 分区标记(契约 §5 逐字)。 */
38
+ export const WB_MARKERS_V1 = Object.freeze({
39
+ modelOpen: '<!-- model -->',
40
+ modelClose: '<!-- /model -->',
41
+ userOpen: '<!-- user -->',
42
+ userClose: '<!-- /user -->',
43
+ })
44
+
45
+ /** 卡片状态(与记忆条目状态同域;缺 `status` 视为 `current`,与 `isCurrentPre` 口径一致)。
46
+ * ⚠️ 2026-09-17 L7 存活标注: 本常量**当前无仓内消费者**(全仓引用计数 = 1, 仅此定义)。
47
+ * 判定 = **对外契约保留**, 不删 —— 理由是它是 WB-FORMAT-CONVENTION 的**状态值域声明**
48
+ * (`current`/`superseded`/`retracted`), 属已发布插件 (@a9i5k4/dsh-auto-memory 3.0.0, 有真实用户)
49
+ * 的公开导出面; 第三方按契约读取状态时需要它作为**单一真源**, 删掉等于把值域契约降级成散落字面量。
50
+ * ⇒ 保留 + 显式标注, 不做"沉默留着"。 */
51
+ export const WB_STATUSES_V1 = Object.freeze(['current', 'superseded', 'retracted'])
52
+
53
+ /** 解析/校验的原因码 → 可读中文。 */
54
+ export const WB_REASONS_V1 = Object.freeze({
55
+ 'not-string': '内容不是字符串',
56
+ 'empty': '内容为空',
57
+ 'no-card': '页面里没有任何卡片标题(`### ` 行)',
58
+ 'anchor-missing': '卡片标题下方缺少锚点行 `<!-- memory:mem_<32hex> -->`',
59
+ 'anchor-format': '锚点格式不合法(必须是 mem_ + 32 位小写十六进制)',
60
+ 'anchor-duplicate': '同一个 id 被两个卡片复用(契约 §2 禁止)',
61
+ 'anchor-mid-body': '锚点不在标题行下方紧跟位置(契约 §2 禁止把锚点写在卡片正文中间)',
62
+ 'user-region-unclosed': '`<!-- user -->` 未闭合',
63
+ 'model-region-unclosed': '`<!-- model -->` 未闭合',
64
+ 'user-region-interleaved': '人机分区标记交错(user 段里又开 model 段)',
65
+ })
66
+
67
+ /** 原因码 → 可读中文(未知码原样返回,排障不吞信息)。
68
+ * ⚠️ 2026-09-17 L7 存活标注: 本函数**当前无仓内调用者**(全仓引用计数 = 1, 仅此定义)。
69
+ * 判定 = **对外契约保留**, 不删 —— 它是 `WB_REASONS_V1`(上文, **有消费者**) 的唯一
70
+ * 取值入口; 删掉函数会让那份码表的"怎么读"离开公开面, 而码表本身是契约的一部分
71
+ * (发布版已有真实用户)。⇒ 保留 + 显式标注。 */
72
+ export function describeWbReasonPre(code) {
73
+ const k = String(code == null ? '' : code)
74
+ return WB_REASONS_V1[k] || k || '未知原因'
75
+ }
76
+
77
+ const isAnchor = (line) => WB_ANCHOR_RE_V1.test(String(line || '').replace(/\r$/, ''))
78
+ const anchorIdOf = (line) => {
79
+ const m = WB_ANCHOR_RE_V1.exec(String(line || '').replace(/\r$/, ''))
80
+ return m ? m[1] : ''
81
+ }
82
+ const isCardTitle = (line) => /^#{3,}\s+\S/.test(String(line || '').replace(/\r$/, ''))
83
+ const titleTextOf = (line) => String(line || '').replace(/\r$/, '').replace(/^#{3,}\s+/, '').trim()
84
+
85
+ /**
86
+ * 内容寻址 id(契约 §2 / A8 预授权默认值):
87
+ * `mem_` + `sha256(workspaceKey + '\u0000' + 页面相对路径 + '\u0000' + 卡片标题)` 前 32 位十六进制。
88
+ *
89
+ * **不复算既有锚点**:本函数只用于"写新卡时该用什么 id",以及测试验证"重排不变、改名即变"。
90
+ * 已有锚点一律**照用**(复算会在标题被小改时把整卡换 id,那是白板线 P1 的 supersede 语义,不在 P0 范围)。
91
+ *
92
+ * @param {string} workspaceKey 工作区标识(canonicalize 后的形态由调用方决定,本函数只做拼接)
93
+ * @param {string} pageRelPath 页面相对路径(如 `handoff/PLAN.md`)
94
+ * @param {string} cardTitle 卡片标题(不含 `### ` 前缀)
95
+ * @returns {string} `mem_<32hex>`
96
+ */
97
+ export function computeWhiteboardCardIdPre(workspaceKey, pageRelPath, cardTitle) {
98
+ const src = String(workspaceKey == null ? '' : workspaceKey) + '\u0000'
99
+ + String(pageRelPath == null ? '' : pageRelPath) + '\u0000'
100
+ + String(cardTitle == null ? '' : cardTitle)
101
+ return 'mem_' + createHash('sha256').update(src).digest('hex').slice(0, 32)
102
+ }
103
+
104
+ /**
105
+ * 解析白板页面 → **规范化投影**(供 `validateMutationBoundaryPre` 消费)。
106
+ *
107
+ * 返回结构刻意做成"通用投影"而不是"白板专有结构":写入门不需要知道 `### ` 是什么,
108
+ * 它只需要知道「有哪些卡片 id」「哪些区域是用户区」「每个卡片的标题与状态」。
109
+ *
110
+ * @param {string} text 页面全文(文件字节按 utf8 解出的字符串)
111
+ * @param {object} [opts]
112
+ * @param {string} [opts.kind] 'plan' | 'handoff' | 'other'(仅用于报告可读性与判据选择,不改变解析)
113
+ * @returns {{ok:boolean, version:string, kind:string, cards:Array, cardIds:string[],
114
+ * userRegions:Array<{cardId:string, text:string, digest:string}>,
115
+ * modelRegions:Array<{cardId:string, text:string}>,
116
+ * issues:Array<{code:string, detail:string, line:number}>, counts:object}}
117
+ */
118
+ export function parseWhiteboardPre(text, opts = {}) {
119
+ const kind = String((opts && opts.kind) || 'plan')
120
+ const issues = []
121
+ if (typeof text !== 'string') {
122
+ return emptyResult(kind, [{ code: 'not-string', detail: '输入类型 ' + typeof text, line: 0 }])
123
+ }
124
+ const raw = text.replace(/^\uFEFF/, '') // 防御:绝不因 BOM 让首行标题判不出来
125
+ if (!raw.trim()) return emptyResult(kind, [{ code: 'empty', detail: '内容为空', line: 0 }])
126
+
127
+ const lines = raw.split('\n')
128
+ const cards = []
129
+ const userRegions = []
130
+ const modelRegions = []
131
+ const seenIds = new Map() // id → 首次出现的行号(重复检测)
132
+ let cur = null
133
+
134
+ // 分区状态:null = 不在任何区;'model' | 'user' = 在当前卡片的哪个区
135
+ let region = null
136
+ let regionStart = 0
137
+ let regionLines = []
138
+
139
+ const closeRegion = () => {
140
+ if (!region || !cur) { region = null; regionLines = []; return }
141
+ const body = regionLines.join('\n')
142
+ if (region === 'user') {
143
+ userRegions.push({ cardId: cur.id || '', text: body, digest: shortDigest(body) })
144
+ } else {
145
+ modelRegions.push({ cardId: cur.id || '', text: body })
146
+ }
147
+ region = null
148
+ regionLines = []
149
+ }
150
+
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i]
153
+ const trimmed = line.replace(/\r$/, '')
154
+
155
+ // ── 分区标记(先于一切判定:标记行本身既不是标题也不是内容) ──
156
+ if (trimmed === WB_MARKERS_V1.userOpen) {
157
+ if (region === 'user') issues.push({ code: 'user-region-interleaved', detail: '重复的 user 开始标记', line: i + 1 })
158
+ if (region === 'model') closeRegion()
159
+ if (!cur) issues.push({ code: 'user-region-unclosed', detail: 'user 段不在任何卡片内(契约 §5 要求每卡分区)', line: i + 1 })
160
+ region = 'user'
161
+ regionStart = i + 1
162
+ regionLines = []
163
+ continue
164
+ }
165
+ if (trimmed === WB_MARKERS_V1.userClose) {
166
+ if (region !== 'user') issues.push({ code: 'user-region-unclosed', detail: 'user 结束标记没有对应的开始标记', line: i + 1 })
167
+ else closeRegion()
168
+ continue
169
+ }
170
+ if (trimmed === WB_MARKERS_V1.modelOpen) {
171
+ if (region === 'user') issues.push({ code: 'user-region-interleaved', detail: 'user 段里又开了 model 段(契约 §5 禁止)', line: i + 1 })
172
+ region = 'model'
173
+ regionStart = i + 1
174
+ regionLines = []
175
+ continue
176
+ }
177
+ if (trimmed === WB_MARKERS_V1.modelClose) {
178
+ if (region !== 'model') issues.push({ code: 'model-region-unclosed', detail: 'model 结束标记没有对应的开始标记', line: i + 1 })
179
+ else closeRegion()
180
+ continue
181
+ }
182
+
183
+ // ── 卡片标题 ──
184
+ if (isCardTitle(trimmed)) {
185
+ if (region) { closeRegion(); issues.push({ code: 'user-region-unclosed', detail: '新卡片开始前上一张卡的分区未闭合', line: i + 1 }) }
186
+ cur = { id: '', title: titleTextOf(trimmed), titleLine: i + 1, anchorLine: 0, status: 'current', expectAnchor: true }
187
+ cards.push(cur)
188
+ continue
189
+ }
190
+
191
+ // ── 锚点行(必须在标题行下方**紧跟**的位置) ──
192
+ if (isAnchor(trimmed)) {
193
+ if (!cur) { issues.push({ code: 'anchor-mid-body', detail: '锚点不在任何卡片标题下方', line: i + 1 }); continue }
194
+ if (i + 1 !== cur.titleLine + 1) {
195
+ issues.push({ code: 'anchor-mid-body', detail: '锚点在标题行下方第 ' + (i + 1 - cur.titleLine) + ' 行(契约为紧跟下一行)', line: i + 1 })
196
+ }
197
+ const id = anchorIdOf(trimmed)
198
+ if (seenIds.has(id)) {
199
+ issues.push({ code: 'anchor-duplicate', detail: 'id ' + id + ' 已被第 ' + seenIds.get(id) + ' 行的卡片占用', line: i + 1 })
200
+ } else seenIds.set(id, i + 1)
201
+ cur.id = id
202
+ cur.anchorLine = i + 1
203
+ cur.expectAnchor = false
204
+ continue
205
+ }
206
+
207
+ // ── 状态声明(`status=superseded` 这类;缺省 current,与 isCurrentPre 口径一致) ──
208
+ const sm = /(?:^|\s)status\s*[=:]\s*(current|superseded|retracted)\b/i.exec(trimmed)
209
+ if (sm && cur && !region) cur.status = sm[1].toLowerCase()
210
+
211
+ if (region) regionLines.push(line)
212
+ }
213
+ if (region) {
214
+ issues.push({
215
+ code: region === 'user' ? 'user-region-unclosed' : 'model-region-unclosed',
216
+ detail: '文件结束时分区仍未闭合(起始行 ' + regionStart + ')', line: lines.length,
217
+ })
218
+ }
219
+
220
+ // ── 逐卡检查:缺锚点 / 锚点格式 ──
221
+ if (!cards.length) {
222
+ // 空页面不算"缺卡"事故(首建白板是合法路径);但**有内容却一张卡都没有**要报出来
223
+ if (raw.trim()) issues.push({ code: 'no-card', detail: '页面有内容但没有任何 `### ` 卡片标题', line: 0 })
224
+ } else {
225
+ for (const c of cards) {
226
+ if (!c.id) issues.push({ code: 'anchor-missing', detail: '卡片「' + c.title + '」缺锚点行', line: c.titleLine })
227
+ }
228
+ }
229
+
230
+ const cardIds = cards.map((c) => c.id).filter(Boolean)
231
+ return {
232
+ ok: issues.length === 0,
233
+ version: WB_CONTRACT_VERSION,
234
+ kind,
235
+ cards: cards.map((c) => ({
236
+ id: c.id, title: c.title, status: c.status,
237
+ titleLine: c.titleLine, anchorLine: c.anchorLine,
238
+ hasUserRegion: userRegions.some((u) => u.cardId && u.cardId === c.id),
239
+ hasModelRegion: modelRegions.some((m) => m.cardId && m.cardId === c.id),
240
+ })),
241
+ cardIds,
242
+ userRegions,
243
+ modelRegions,
244
+ issues,
245
+ counts: {
246
+ cards: cards.length,
247
+ anchored: cardIds.length,
248
+ unanchored: cards.length - cardIds.length,
249
+ userRegions: userRegions.length,
250
+ modelRegions: modelRegions.length,
251
+ lines: lines.length,
252
+ },
253
+ }
254
+ }
255
+
256
+ const shortDigest = (s) => createHash('sha256').update(String(s == null ? '' : s)).digest('hex').slice(0, 16)
257
+
258
+ function emptyResult(kind, issues) {
259
+ return {
260
+ ok: false, version: WB_CONTRACT_VERSION, kind,
261
+ cards: [], cardIds: [], userRegions: [], modelRegions: [], issues,
262
+ counts: { cards: 0, anchored: 0, unanchored: 0, userRegions: 0, modelRegions: 0, lines: 0 },
263
+ }
264
+ }
265
+
266
+ // ─────────────────────────────────────────────────────────────────────────────
267
+ // ★M2(2026-09-18)白板 lint —— 契约 §6「零 token 可判的四类」
268
+ //
269
+ // **硬约束(契约 §6 尾注,不可越)**:lint **只报告,不自动改**。
270
+ // 「任何自动修正都会把白板变成状态机(违反边界)」
271
+ // ⇒ 本模块是**纯函数**:不读文件、不写文件、不 import fs。
272
+ // 输入什么就判什么,输出问题清单。任何写盘能力都不得塞进这里(报告与写入分离)。
273
+ //
274
+ // 四类判据(§6 原文)与**本实现的口径**(判据纪律:宁可漏判,不可误伤):
275
+ // ① 孤立条目:无入站引用 → 全部卡正文里都找不到它的 id/标题
276
+ // ② 陈旧:status≠current 或超期 current → 状态直读 + 与 now 比日期
277
+ // ③ 被提及却无独立卡 → ★ 只判**客观可证**的一种:正文引用了
278
+ // `mem_<32hex>` 但该 id 没有对应卡片("什么算概念"无法零 token 判定,不猜)
279
+ // ④ 缺交叉引用 → ★ 只判**客观可证**的一种:两卡共享 ≥N 个 tag
280
+ // 却互不引用("什么算相关"由 tag 交集代言,不猜语义)
281
+ // ⑤ 矛盾检测 → 需 LLM,**不在本模块**(契约 §6:必须手动触发,不进自动路径)
282
+ // ─────────────────────────────────────────────────────────────────────────────
283
+
284
+ export const WB_LINT_VERSION = 'wb_lint_v1'
285
+
286
+ /** lint 问题码(§6 四类;⑤ 矛盾检测需 LLM,不在此列)。 */
287
+ export const WB_LINT_CODES_V1 = Object.freeze({
288
+ ORPHAN: 'orphan', // ① 孤立条目
289
+ INACTIVE: 'inactive', // ② 陈旧(已标 superseded/retracted)
290
+ EXPIRED: 'expired', // ② 陈旧(仍标 current 但超阈值)
291
+ DANGLING_REF: 'dangling-ref', // ③ 被引用却无卡(悬空锚点引用)
292
+ NO_CROSS_REF: 'no-cross-ref', // ④ 缺交叉引用(共享 tag 却互不引用)
293
+ })
294
+
295
+ /** 默认口径。 */
296
+ export const WB_LINT_DEFAULTS_V1 = Object.freeze({
297
+ staleDays: 90, // ② 仍标 current 但日期早于 now-90d ⇒ 报 expired
298
+ minSharedTags: 2, // ④ 共享 tag 数 ≥ 2 才算"应互链"
299
+ })
300
+
301
+ const LINT_ANCHOR_RE = /mem_[0-9a-f]{32}/g
302
+
303
+ const asDateMs = (v) => {
304
+ const s = String(v == null ? '' : v)
305
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
306
+ if (!m) return NaN
307
+ const t = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
308
+ return Number.isFinite(t) ? t : NaN
309
+ }
310
+
311
+ /**
312
+ * 白板 lint(**只读纯函数**,契约 §6)。
313
+ *
314
+ * @param {Array<object>|{entries?:Array<object>}} entries 条目数组;每项可含
315
+ * `{id, title, status, date, ts, tags, body}`。`body` 为该卡正文(用于扫描引用);
316
+ * 缺 `body` 时退化为用 `title+id` 判引用(**不报错**——信息少就少判,不猜)。
317
+ * @param {{now?:Date|string|number, staleDays?:number, minSharedTags?:number, corpus?:string}} [opts]
318
+ * `corpus` 为白板之外的**额外文本**(如项目笔记/账本全文),一并参与"入站引用"扫描。
319
+ * @returns {{version:string, ok:boolean, findings:Array<object>, counts:object}}
320
+ */
321
+ export function lintWhiteboardPre(entries, opts = {}) {
322
+ const o = opts && typeof opts === 'object' ? opts : {}
323
+ const list = Array.isArray(entries) ? entries
324
+ : (entries && Array.isArray(entries.entries) ? entries.entries : [])
325
+ const staleDays = Number.isFinite(Number(o.staleDays)) ? Number(o.staleDays) : WB_LINT_DEFAULTS_V1.staleDays
326
+ const minSharedTags = Number.isFinite(Number(o.minSharedTags)) ? Number(o.minSharedTags) : WB_LINT_DEFAULTS_V1.minSharedTags
327
+
328
+ const nowMs0 = o.now instanceof Date ? o.now.getTime()
329
+ : (typeof o.now === 'number' ? o.now : asDateMs(o.now))
330
+ const nowMs = Number.isFinite(nowMs0) ? nowMs0 : Date.now()
331
+
332
+ // ── 归一化(**永不抛**:坏条目降级为跳过,不打断整轮 lint)──
333
+ const cards = []
334
+ let skipped = 0
335
+ for (const e of list) {
336
+ if (!e || typeof e !== 'object') { skipped++; continue }
337
+ const id = String(e.id || '')
338
+ const title = String(e.title || '').trim()
339
+ if (!id && !title) { skipped++; continue }
340
+ const status = String(e.status || 'current').trim() || 'current'
341
+ const dateSrc = e.date || e.ts || ''
342
+ const tags = Array.isArray(e.tags) ? e.tags.map((t) => String(t)).filter(Boolean) : []
343
+ cards.push({ id, title, status, tags, dateMs: asDateMs(dateSrc), dateRaw: String(dateSrc || '') })
344
+ }
345
+
346
+ // ── 引用语料:所有卡正文 + 外部 corpus ──
347
+ const parts = []
348
+ for (const e of list) {
349
+ if (e && typeof e === 'object') {
350
+ if (e.body != null) parts.push(String(e.body))
351
+ if (e.preview != null) parts.push(String(e.preview))
352
+ }
353
+ }
354
+ if (o.corpus != null) parts.push(String(o.corpus))
355
+ const haystack = parts.join('\n')
356
+
357
+ const findings = []
358
+ const push = (code, card, detail) => {
359
+ findings.push({ code, id: card ? card.id : '', title: card ? card.title : '', detail })
360
+ }
361
+
362
+ // ── ① 孤立条目:无入站引用 ──
363
+ // 判据:除自己以外,任何卡正文/外部语料都未出现它的 id,也未出现它的标题。
364
+ // 纪律:**只报"全无提及"**;标题过短(<2 字符)时跳过,避免单字误命中(宁可漏判)。
365
+ for (const c of cards) {
366
+ let referenced = false
367
+ if (c.id) {
368
+ const re = new RegExp(c.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
369
+ const hits = haystack.match(re)
370
+ // 自己正文里出现自己的 id 不算入站引用
371
+ const selfBody = String((list.find((x) => x && x.id === c.id) || {}).body || '')
372
+ const selfHits = selfBody.match(re)
373
+ const inHits = (hits ? hits.length : 0) - (selfHits ? selfHits.length : 0)
374
+ if (inHits > 0) referenced = true
375
+ }
376
+ if (!referenced && c.title && c.title.length >= 2) {
377
+ if (haystack.includes(c.title)) {
378
+ // 标题出现次数 > 自身正文里的次数 ⇒ 有外部提及
379
+ const selfBody = String((list.find((x) => x && x.id === c.id) || {}).body || '')
380
+ const total = haystack.split(c.title).length - 1
381
+ const selfN = selfBody.split(c.title).length - 1
382
+ if (total - selfN > 0) referenced = true
383
+ }
384
+ }
385
+ if (!referenced) push(WB_LINT_CODES_V1.ORPHAN, c, '无任何其他卡片或语料引用它的 id/标题')
386
+ }
387
+
388
+ // ── ② 陈旧 ──
389
+ const cutoff = nowMs - staleDays * 86400000
390
+ for (const c of cards) {
391
+ if (c.status !== 'current') {
392
+ push(WB_LINT_CODES_V1.INACTIVE, c, 'status=' + c.status + '(已作废,仍在白板内)')
393
+ continue
394
+ }
395
+ if (Number.isFinite(c.dateMs) && c.dateMs < cutoff) {
396
+ const ageDays = Math.floor((nowMs - c.dateMs) / 86400000)
397
+ push(WB_LINT_CODES_V1.EXPIRED, c, '仍标 current 但日期为 ' + c.dateRaw + '(' + ageDays + ' 天前,超阈值 ' + staleDays + ' 天)')
398
+ }
399
+ }
400
+
401
+ // ── ③ 悬空锚点引用:正文引用了 mem_id 但没有对应卡片 ──
402
+ // 契约 §2 的 id 是"内容寻址、可复算"的 ⇒ 引用了不存在的 id 是**客观可证**的断裂。
403
+ const knownIds = new Set(cards.map((c) => c.id).filter(Boolean))
404
+ const allIds = new Set()
405
+ for (const m of haystack.match(LINT_ANCHOR_RE) || []) allIds.add(m)
406
+ for (const id of allIds) {
407
+ if (!knownIds.has(id)) {
408
+ findings.push({
409
+ code: WB_LINT_CODES_V1.DANGLING_REF, id, title: '',
410
+ detail: '正文引用了 ' + id + ',但白板内没有该 id 的卡片(悬空引用)',
411
+ })
412
+ }
413
+ }
414
+
415
+ // ── ④ 缺交叉引用:共享 ≥minSharedTags 个 tag 却互不引用 ──
416
+ for (let i = 0; i < cards.length; i++) {
417
+ for (let j = i + 1; j < cards.length; j++) {
418
+ const a = cards[i], b = cards[j]
419
+ if (!a.tags.length || !b.tags.length) continue
420
+ const shared = a.tags.filter((t) => b.tags.includes(t))
421
+ if (shared.length < minSharedTags) continue
422
+ const aRefB = (a.id && b.id && String((list.find((x) => x && x.id === a.id) || {}).body || '').includes(b.id)) ||
423
+ (b.title && String((list.find((x) => x && x.id === a.id) || {}).body || '').includes(b.title))
424
+ const bRefA = (a.id && b.id && String((list.find((x) => x && x.id === b.id) || {}).body || '').includes(a.id)) ||
425
+ (a.title && String((list.find((x) => x && x.id === b.id) || {}).body || '').includes(a.title))
426
+ if (!aRefB && !bRefA) {
427
+ findings.push({
428
+ code: WB_LINT_CODES_V1.NO_CROSS_REF, id: a.id, title: a.title,
429
+ detail: '与「' + (b.title || b.id) + '」共享 ' + shared.length + ' 个 tag(' + shared.join(', ') + ')但互不引用',
430
+ })
431
+ }
432
+ }
433
+ }
434
+
435
+ const byCode = {}
436
+ for (const f of findings) byCode[f.code] = (byCode[f.code] || 0) + 1
437
+
438
+ return {
439
+ version: WB_LINT_VERSION,
440
+ ok: findings.length === 0,
441
+ findings,
442
+ counts: {
443
+ cards: cards.length,
444
+ skipped,
445
+ findings: findings.length,
446
+ byCode,
447
+ // ⑤ 明确声明"未判" —— 不许静默省略(契约 §6:需 LLM,必须手动触发)
448
+ notChecked: ['contradiction'],
449
+ },
450
+ }
451
+ }
452
+
453
+ /**
454
+ * 从投影里抽出"**必须逐字节保留**的用户区"(供写入门做前后比对)。
455
+ *
456
+ * 只返回**有 cardId 的用户区**:没有 cardId 的用户区无法与卡片对应,
457
+ * 其"是否被保留"无从判定(那属于 lint/格式问题,不是写入门能拦的丢卡问题)。
458
+ *
459
+ * @param {object} projection `parseWhiteboardPre` 的返回值
460
+ * @returns {Array<{key:string, digest:string, chars:number, cardId:string}>}
461
+ */
462
+ export function extractProtectedRegionsPre(projection) {
463
+ const p = projection && typeof projection === 'object' ? projection : {}
464
+ const out = []
465
+ for (const u of Array.isArray(p.userRegions) ? p.userRegions : []) {
466
+ const cardId = String((u && u.cardId) || '')
467
+ if (!cardId) continue
468
+ out.push({
469
+ key: 'user:' + cardId,
470
+ cardId,
471
+ digest: String((u && u.digest) || shortDigest(u && u.text)),
472
+ chars: String((u && u.text) || '').length,
473
+ })
474
+ }
475
+ return out
476
+ }
477
+
478
+ /**
479
+ * 把白板投影适配成写入门要的**规范化投影**(`{beforeIds, afterIds, protectedRegions}`)。
480
+ *
481
+ * @param {object} projection `parseWhiteboardPre` 的返回值
482
+ * @returns {{beforeIds?:string[], afterIds?:string[], protectedRegions:Array, cards:Array, issues:Array}}
483
+ */
484
+ export function toMutationProjectionPre(projection) {
485
+ const p = projection && typeof projection === 'object' ? projection : {}
486
+ return {
487
+ afterIds: Array.isArray(p.cardIds) ? p.cardIds.slice() : [],
488
+ protectedRegions: extractProtectedRegionsPre(p),
489
+ cards: Array.isArray(p.cards) ? p.cards : [],
490
+ issues: Array.isArray(p.issues) ? p.issues : [],
491
+ }
492
+ }
493
+
494
+ // ═══════════════════════════════════════════════════════════════════════════
495
+ // 判据门(criteria gate)—— 账本用 H1–H4/S1–S4;PLAN 用 P-H1/P-H2/P-S1
496
+ //
497
+ // ⚠️ **判据不能张冠李戴**(v2 修正,务必遵守;`ROUND3 §2.2` 把我方原写法判为实质错误):
498
+ // H1–H4/S1–S4 是**交接账本**的判据;PLAN 是自由全貌文档(节名不固定,P7 老化按标题分类),
499
+ // 其判据**刻意保持最弱** = P-H1/P-H2/P-S1。把账本判据套给 PLAN 会误拒合法白板。
500
+ //
501
+ // **与共同保护的关系**(ROUND3 §3.7 第 4 条):本判据门是**可选质量门**,
502
+ // `criteriaGate=false` 只退掉它;**丢卡 / 用户区 / 重复 id 三条保护在
503
+ // `memory-mutation.js` 里,无条件生效**,任何开关都绕不过。
504
+ // ═══════════════════════════════════════════════════════════════════════════
505
+
506
+ export const CRITERIA_GATE_VERSION = 'wb_criteria_v1'
507
+
508
+ /** 账本四段标题(逐字匹配;与 `handoff-anchor.js` 的权重表同源,不另写一份)。 */
509
+ export const HANDOFF_REQUIRED_SECTIONS_V1 = Object.freeze(
510
+ HANDOFF_LEDGER_SECTION_WEIGHTS_V1.map((x) => x.title),
511
+ )
512
+
513
+ /** 占位符黑名单(H3):段 body 只含这些即视为未填写。 */
514
+ export const CRITERIA_PLACEHOLDERS_V1 = Object.freeze(['(待补充)', '(待补充)', 'todo', '同上', '略', 'n/a', '待补充'])
515
+
516
+ /** 账本/白板判据的阈值(与 `sanitizeForWrite` 上限同源,先于它执行以免双重截断语义混乱)。 */
517
+ export const CRITERIA_LIMITS_V1 = Object.freeze({
518
+ handoffMaxChars: 8000,
519
+ planMaxChars: 200000,
520
+ sectionMinChars: 20,
521
+ sectionMaxLines: 5,
522
+ })
523
+
524
+ const PLACEHOLDER_SET = new Set(CRITERIA_PLACEHOLDERS_V1.map((x) => x.toLowerCase()))
525
+
526
+ /** 段 body 的"实质内容"行(去空行、去纯占位行)。 */
527
+ function bodyRealLines(body) {
528
+ return (Array.isArray(body) ? body : [])
529
+ .map((l) => String(l == null ? '' : l).trim())
530
+ .filter((l) => l && !PLACEHOLDER_SET.has(l.toLowerCase()))
531
+ }
532
+
533
+ /**
534
+ * 交接账本判据(H1–H4 硬;S1–S4 软)。**纯函数、零 IO**。
535
+ *
536
+ * @param {string} text 账本全文(写入函数会自带 `# 交接账本 · <日期> <时间>` 标题行,故解析 preamble 非空)
537
+ * @returns {{ok:boolean, version:string, target:'handoff', hardPass:boolean,
538
+ * hard:Array, soft:Array, report:object}}
539
+ */
540
+ export function checkHandoffCriteriaPre(text) {
541
+ const src = typeof text === 'string' ? text : ''
542
+ const parsed = parseHandoffLedgerPre(src)
543
+ const hard = []
544
+ const soft = []
545
+
546
+ // H1 四段标题齐全且逐字匹配
547
+ const gotTitles = parsed ? parsed.sections.map((s) => String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()) : []
548
+ const missing = HANDOFF_REQUIRED_SECTIONS_V1.filter((t) => !gotTitles.includes(t))
549
+ hard.push({
550
+ id: 'H1', pass: missing.length === 0, missing: missing.slice(),
551
+ detail: missing.length
552
+ ? '缺少或标题不逐字匹配的四段标题:' + missing.join('、') + '(标题错会让权重化截断失效并让注入端解析失败)'
553
+ : '四段标题齐全且逐字匹配',
554
+ })
555
+
556
+ // H2 每段 body 非空(≥1 非空行且合计 ≥20 字符)
557
+ const thinSections = []
558
+ if (parsed) {
559
+ for (const s of parsed.sections) {
560
+ const name = String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()
561
+ if (!HANDOFF_REQUIRED_SECTIONS_V1.includes(name)) continue
562
+ const real = bodyRealLines(s.body)
563
+ const chars = real.join('').length
564
+ if (!real.length || chars < CRITERIA_LIMITS_V1.sectionMinChars) thinSections.push(name + '(' + chars + ' 字符)')
565
+ }
566
+ } else {
567
+ thinSections.push('(无法解析出任何段)')
568
+ }
569
+ hard.push({
570
+ id: 'H2', pass: thinSections.length === 0, missing: thinSections.slice(),
571
+ detail: thinSections.length
572
+ ? '这些段为空或少于 ' + CRITERIA_LIMITS_V1.sectionMinChars + ' 字符:' + thinSections.join('、')
573
+ : '四段 body 均非空且达到最小长度',
574
+ })
575
+
576
+ // H3 无占位符行
577
+ const placeholderHits = []
578
+ if (parsed) {
579
+ for (const s of parsed.sections) {
580
+ const name = String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()
581
+ const real = bodyRealLines(s.body)
582
+ const allBody = (Array.isArray(s.body) ? s.body : []).map((l) => String(l == null ? '' : l).trim()).filter(Boolean)
583
+ if (allBody.length && !real.length) placeholderHits.push(name)
584
+ }
585
+ }
586
+ hard.push({
587
+ id: 'H3', pass: placeholderHits.length === 0, missing: placeholderHits.slice(),
588
+ detail: placeholderHits.length
589
+ ? '这些段的正文只有占位符(' + CRITERIA_PLACEHOLDERS_V1.slice(0, 4).join('/') + ' 等):' + placeholderHits.join('、')
590
+ : '无纯占位符段',
591
+ })
592
+
593
+ // H4 总长 ≤ 8000
594
+ hard.push({
595
+ id: 'H4', pass: src.length <= CRITERIA_LIMITS_V1.handoffMaxChars, missing: [],
596
+ detail: '总长 ' + src.length + ' 字符(上限 ' + CRITERIA_LIMITS_V1.handoffMaxChars + ')',
597
+ })
598
+
599
+ // S1 每段 ≤5 行(软·警告;prompt 纪律既有约定)
600
+ const longSections = []
601
+ if (parsed) {
602
+ for (const s of parsed.sections) {
603
+ const name = String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()
604
+ if (!HANDOFF_REQUIRED_SECTIONS_V1.includes(name)) continue
605
+ const n = bodyRealLines(s.body).length
606
+ if (n > CRITERIA_LIMITS_V1.sectionMaxLines) longSections.push(name + '(' + n + ' 行)')
607
+ }
608
+ }
609
+ soft.push({ id: 'S1', pass: longSections.length === 0, detail: longSections.length ? '超过 5 行的段:' + longSections.join('、') : '各段均 ≤5 行' })
610
+
611
+ // S2「已试方案与失败原因」写成「方案→失败原因」且保留报错关键词
612
+ const failSec = parsed ? parsed.sections.find((s) => String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim() === '已试方案与失败原因') : null
613
+ const failBody = failSec ? bodyRealLines(failSec.body).join('\n') : ''
614
+ const hexArrow = /→|->|⇒/.test(failBody)
615
+ const errWord = /失败|报错|错误|回滚|error|fail|bug/i.test(failBody)
616
+ soft.push({
617
+ id: 'S2', pass: hexArrow && errWord,
618
+ detail: (hexArrow ? '' : '缺「方案→失败原因」箭头;') + (errWord ? '' : '缺关键报错词;') || '失败项格式良好',
619
+ })
620
+
621
+ // S3「进度与下一步」含可执行特征(路径分隔符 / 反引号代码 / 命令动词)
622
+ const nextSec = parsed ? parsed.sections.find((s) => String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim() === '进度与下一步') : null
623
+ const nextBody = nextSec ? bodyRealLines(nextSec.body).join('\n') : ''
624
+ const execish = /[\\/][\w.-]+|`[^`]+`|\bnode\b|\bnpm\b|\bgit\b|\brun\b|执行|运行|命令/i.test(nextBody)
625
+ soft.push({ id: 'S3', pass: execish, detail: execish ? '下一步含可执行特征' : '下一步看不出可直接执行的第一步(软提示)' })
626
+
627
+ // S4 无临时信息(低置信启发,仅 diag)
628
+ const tempish = /临时路径|tmp\\|\\\\tmp|搜索结果[::]|console\.log\(/i.test(src)
629
+ soft.push({ id: 'S4', pass: !tempish, detail: tempish ? '疑似含临时信息(低置信,仅诊断)' : '未见明显临时信息' })
630
+
631
+ return {
632
+ ok: hard.every((h) => h.pass), version: CRITERIA_GATE_VERSION, target: 'handoff',
633
+ hardPass: hard.every((h) => h.pass), hard, soft,
634
+ report: { chars: src.length, sections: gotTitles.length, requiredSections: HANDOFF_REQUIRED_SECTIONS_V1.length },
635
+ }
636
+ }
637
+
638
+ /**
639
+ * 白板 PLAN 判据(P-H1/P-H2 硬;P-S1 软)。**刻意保持最弱**(v2 修正)。
640
+ *
641
+ * P-H1 至少一个非空 `## ` 顶层节(与 P7 老化的节切分逻辑一致)—— 防"全部节被老化走"的空白板;
642
+ * P-H2 ≤ 200000 字符;P-S1 含至少一处前瞻内容(软)。
643
+ *
644
+ * @param {string} text 白板全文
645
+ * @param {object} [projection] 已有的 `parseWhiteboardPre` 结果(省略则只看文本,不看卡片)
646
+ */
647
+ export function checkPlanCriteriaPre(text, projection) {
648
+ const src = typeof text === 'string' ? text : ''
649
+ const hard = []
650
+ const soft = []
651
+ // 与 P7 老化同一口径:按 `## ` 切顶层节(`### ` 不算顶层节)
652
+ const sections = src.split(/(?=^## )/m).filter((s) => /^## /.test(s))
653
+ const nonEmpty = sections.filter((s) => {
654
+ const body = s.split('\n').slice(1).join('\n').trim()
655
+ return body.length >= CRITERIA_LIMITS_V1.sectionMinChars
656
+ })
657
+ hard.push({
658
+ id: 'P-H1', pass: nonEmpty.length >= 1, missing: nonEmpty.length ? [] : ['(无非空 ## 顶层节)'],
659
+ detail: nonEmpty.length
660
+ ? '有 ' + nonEmpty.length + ' 个非空顶层节(共 ' + sections.length + ' 个)'
661
+ : '没有任何非空 `## ` 顶层节(' + CRITERIA_LIMITS_V1.sectionMinChars + ' 字符以上)—— 这是"白板被整篇覆盖成骨架/空白板"的形态',
662
+ })
663
+ hard.push({
664
+ id: 'P-H2', pass: src.length <= CRITERIA_LIMITS_V1.planMaxChars, missing: [],
665
+ detail: '总长 ' + src.length + ' 字符(上限 ' + CRITERIA_LIMITS_V1.planMaxChars + ')',
666
+ })
667
+ const forward = /下一步|待办|计划|todo/i.test(src)
668
+ soft.push({ id: 'P-S1', pass: forward, detail: forward ? '含前瞻内容' : '未见前瞻内容(下一步/待办/计划)—— 软提示,不拦截' })
669
+
670
+ // 卡片情况只作**报告**,不作 PLAN 硬判据(P0 既有白板没有锚点,硬判会立刻误拒所有现存白板)。
671
+ // 锚点完备性属白板线 P1/P2,不在此拦截。
672
+ const cards = projection && Array.isArray(projection.cards) ? projection.cards : null
673
+ return {
674
+ ok: hard.every((h) => h.pass), version: CRITERIA_GATE_VERSION, target: 'plan',
675
+ hardPass: hard.every((h) => h.pass), hard, soft,
676
+ report: {
677
+ chars: src.length, sections: sections.length, nonEmptySections: nonEmpty.length,
678
+ cards: cards ? cards.length : null, anchored: projection ? (projection.cardIds || []).length : null,
679
+ },
680
+ }
681
+ }
682
+
683
+ /** 判据报告 → 给模型的可执行拒绝文案(缺什么、哪段空、占位符原文,逐条列出)。 */
684
+ export function criteriaRefusalTextPre(res) {
685
+ const r = res && typeof res === 'object' ? res : {}
686
+ const failed = (Array.isArray(r.hard) ? r.hard : []).filter((h) => !h.pass)
687
+ if (!failed.length) return ''
688
+ return '写入被判据门拦截(' + failed.map((h) => h.id).join('/') + '),原文件未改动:\n'
689
+ + failed.map((h) => '· [' + h.id + '] ' + h.detail).join('\n')
690
+ + '\n请补齐后重试。'
691
+ }