@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.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 (104) hide show
  1. package/README.md +171 -1
  2. package/README.zh-CN.md +171 -1
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/HANDOFF-CRITERIA.md +92 -0
  5. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  6. package/docs/USER-GUIDE.en.md +56 -1
  7. package/docs/USER-GUIDE.zh-CN.md +57 -2
  8. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  9. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  10. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  11. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  12. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  13. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  14. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  15. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  16. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  17. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  18. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  19. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  20. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  21. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  22. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  23. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  24. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  25. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  26. package/docs/internal/KICKOFF-P0.md +254 -0
  27. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  28. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  29. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  30. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  31. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  32. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  33. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  34. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  35. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  36. package/docs/internal/ROADMAP-20260917-WEEK.md +305 -0
  37. package/docs/internal/ROADMAP.md +106 -0
  38. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  39. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +175 -0
  40. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  41. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  42. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  43. package/docs/internal/THREE-LAYER-CONTRACT.md +210 -0
  44. package/docs/internal/TODO-BACKLOG.md +263 -142
  45. package/docs/internal/TODO-GRAPH.html +715 -0
  46. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  47. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  48. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  49. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  50. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  51. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  52. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  53. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  54. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  55. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  56. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  57. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  58. package/docs/prompts/M8-3-enable-verify.md +49 -49
  59. package/lib/acceptance.js +71 -0
  60. package/lib/activation-host.js +90 -9
  61. package/lib/activation-inbox.js +25 -7
  62. package/lib/board-mode.js +30 -0
  63. package/lib/client.js +878 -75
  64. package/lib/context-bridge.js +2 -2
  65. package/lib/context-host.js +70 -6
  66. package/lib/engine-identity.js +149 -0
  67. package/lib/engine-switch.js +247 -0
  68. package/lib/episodic-store.js +11 -10
  69. package/lib/evidence-store.js +2 -2
  70. package/lib/fact-store.js +1 -1
  71. package/lib/fs-retry.js +46 -0
  72. package/lib/index.js +1987 -153
  73. package/lib/intent-clean-safe.js +40 -0
  74. package/lib/intent-clean.js +12 -16
  75. package/lib/l0-extract.js +263 -149
  76. package/lib/l0-index-sync.js +195 -0
  77. package/lib/l0-index.js +349 -239
  78. package/lib/ledger-criteria.js +142 -0
  79. package/lib/m7-index-sync-host.js +65 -4
  80. package/lib/m7-wire.js +3 -3
  81. package/lib/memory-anchor.js +56 -1
  82. package/lib/memory-envelope.js +252 -0
  83. package/lib/memory-hub.js +14 -4
  84. package/lib/memory-mutation.js +246 -0
  85. package/lib/memory-writer.js +204 -24
  86. package/lib/procedure-observation.js +48 -0
  87. package/lib/procedure-store.js +34 -17
  88. package/lib/python-setup.js +1 -1
  89. package/lib/rerank-host.js +160 -0
  90. package/lib/rules-layer.js +261 -0
  91. package/lib/semantic-js.js +15 -0
  92. package/lib/shadow-retrieval.js +3 -3
  93. package/lib/state-commit.js +245 -0
  94. package/lib/subagent-gc.js +4 -8
  95. package/lib/tier-layer-inject.js +650 -0
  96. package/lib/tier0-catalog.js +693 -0
  97. package/lib/water-window.js +263 -186
  98. package/lib/wb-contract.js +495 -0
  99. package/lib/wb-sidecar.js +839 -0
  100. package/lib/ws-overview-rank.js +2 -2
  101. package/package.json +1 -1
  102. package/python/m7_embedding_v1.py +5 -5
  103. package/python/worker_semantic_v1.py +17 -6
  104. package/python/worker_v1.py +38 -4
@@ -0,0 +1,495 @@
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
+ export const WB_STATUSES_V1 = Object.freeze(['current', 'superseded', 'retracted'])
47
+
48
+ /** 解析/校验的原因码 → 可读中文。 */
49
+ export const WB_REASONS_V1 = Object.freeze({
50
+ 'not-string': '内容不是字符串',
51
+ 'empty': '内容为空',
52
+ 'no-card': '页面里没有任何卡片标题(`### ` 行)',
53
+ 'anchor-missing': '卡片标题下方缺少锚点行 `<!-- memory:mem_<32hex> -->`',
54
+ 'anchor-format': '锚点格式不合法(必须是 mem_ + 32 位小写十六进制)',
55
+ 'anchor-duplicate': '同一个 id 被两个卡片复用(契约 §2 禁止)',
56
+ 'anchor-mid-body': '锚点不在标题行下方紧跟位置(契约 §2 禁止把锚点写在卡片正文中间)',
57
+ 'user-region-unclosed': '`<!-- user -->` 未闭合',
58
+ 'model-region-unclosed': '`<!-- model -->` 未闭合',
59
+ 'user-region-interleaved': '人机分区标记交错(user 段里又开 model 段)',
60
+ })
61
+
62
+ /** 原因码 → 可读中文(未知码原样返回,排障不吞信息)。 */
63
+ export function describeWbReasonPre(code) {
64
+ const k = String(code == null ? '' : code)
65
+ return WB_REASONS_V1[k] || k || '未知原因'
66
+ }
67
+
68
+ const isAnchor = (line) => WB_ANCHOR_RE_V1.test(String(line || '').replace(/\r$/, ''))
69
+ const anchorIdOf = (line) => {
70
+ const m = WB_ANCHOR_RE_V1.exec(String(line || '').replace(/\r$/, ''))
71
+ return m ? m[1] : ''
72
+ }
73
+ const isCardTitle = (line) => /^#{3,}\s+\S/.test(String(line || '').replace(/\r$/, ''))
74
+ const titleTextOf = (line) => String(line || '').replace(/\r$/, '').replace(/^#{3,}\s+/, '').trim()
75
+
76
+ /**
77
+ * 内容寻址 id(契约 §2 / A8 预授权默认值):
78
+ * `mem_` + `sha256(workspaceKey + '\u0000' + 页面相对路径 + '\u0000' + 卡片标题)` 前 32 位十六进制。
79
+ *
80
+ * **不复算既有锚点**:本函数只用于"写新卡时该用什么 id",以及测试验证"重排不变、改名即变"。
81
+ * 已有锚点一律**照用**(复算会在标题被小改时把整卡换 id,那是白板线 P1 的 supersede 语义,不在 P0 范围)。
82
+ *
83
+ * @param {string} workspaceKey 工作区标识(canonicalize 后的形态由调用方决定,本函数只做拼接)
84
+ * @param {string} pageRelPath 页面相对路径(如 `handoff/PLAN.md`)
85
+ * @param {string} cardTitle 卡片标题(不含 `### ` 前缀)
86
+ * @returns {string} `mem_<32hex>`
87
+ */
88
+ export function computeWhiteboardCardIdPre(workspaceKey, pageRelPath, cardTitle) {
89
+ const src = String(workspaceKey == null ? '' : workspaceKey) + '\u0000'
90
+ + String(pageRelPath == null ? '' : pageRelPath) + '\u0000'
91
+ + String(cardTitle == null ? '' : cardTitle)
92
+ return 'mem_' + createHash('sha256').update(src).digest('hex').slice(0, 32)
93
+ }
94
+
95
+ /**
96
+ * 解析白板页面 → **规范化投影**(供 `validateMutationBoundaryPre` 消费)。
97
+ *
98
+ * 返回结构刻意做成"通用投影"而不是"白板专有结构":写入门不需要知道 `### ` 是什么,
99
+ * 它只需要知道「有哪些卡片 id」「哪些区域是用户区」「每个卡片的标题与状态」。
100
+ *
101
+ * @param {string} text 页面全文(文件字节按 utf8 解出的字符串)
102
+ * @param {object} [opts]
103
+ * @param {string} [opts.kind] 'plan' | 'handoff' | 'other'(仅用于报告可读性与判据选择,不改变解析)
104
+ * @returns {{ok:boolean, version:string, kind:string, cards:Array, cardIds:string[],
105
+ * userRegions:Array<{cardId:string, text:string, digest:string}>,
106
+ * modelRegions:Array<{cardId:string, text:string}>,
107
+ * issues:Array<{code:string, detail:string, line:number}>, counts:object}}
108
+ */
109
+ export function parseWhiteboardPre(text, opts = {}) {
110
+ const kind = String((opts && opts.kind) || 'plan')
111
+ const issues = []
112
+ if (typeof text !== 'string') {
113
+ return emptyResult(kind, [{ code: 'not-string', detail: '输入类型 ' + typeof text, line: 0 }])
114
+ }
115
+ const raw = text.replace(/^\uFEFF/, '') // 防御:绝不因 BOM 让首行标题判不出来
116
+ if (!raw.trim()) return emptyResult(kind, [{ code: 'empty', detail: '内容为空', line: 0 }])
117
+
118
+ const lines = raw.split('\n')
119
+ const cards = []
120
+ const userRegions = []
121
+ const modelRegions = []
122
+ const seenIds = new Map() // id → 首次出现的行号(重复检测)
123
+ let cur = null
124
+
125
+ // 分区状态:null = 不在任何区;'model' | 'user' = 在当前卡片的哪个区
126
+ let region = null
127
+ let regionStart = 0
128
+ let regionLines = []
129
+
130
+ const closeRegion = () => {
131
+ if (!region || !cur) { region = null; regionLines = []; return }
132
+ const body = regionLines.join('\n')
133
+ if (region === 'user') {
134
+ userRegions.push({ cardId: cur.id || '', text: body, digest: shortDigest(body) })
135
+ } else {
136
+ modelRegions.push({ cardId: cur.id || '', text: body })
137
+ }
138
+ region = null
139
+ regionLines = []
140
+ }
141
+
142
+ for (let i = 0; i < lines.length; i++) {
143
+ const line = lines[i]
144
+ const trimmed = line.replace(/\r$/, '')
145
+
146
+ // ── 分区标记(先于一切判定:标记行本身既不是标题也不是内容) ──
147
+ if (trimmed === WB_MARKERS_V1.userOpen) {
148
+ if (region === 'user') issues.push({ code: 'user-region-interleaved', detail: '重复的 user 开始标记', line: i + 1 })
149
+ if (region === 'model') closeRegion()
150
+ if (!cur) issues.push({ code: 'user-region-unclosed', detail: 'user 段不在任何卡片内(契约 §5 要求每卡分区)', line: i + 1 })
151
+ region = 'user'
152
+ regionStart = i + 1
153
+ regionLines = []
154
+ continue
155
+ }
156
+ if (trimmed === WB_MARKERS_V1.userClose) {
157
+ if (region !== 'user') issues.push({ code: 'user-region-unclosed', detail: 'user 结束标记没有对应的开始标记', line: i + 1 })
158
+ else closeRegion()
159
+ continue
160
+ }
161
+ if (trimmed === WB_MARKERS_V1.modelOpen) {
162
+ if (region === 'user') issues.push({ code: 'user-region-interleaved', detail: 'user 段里又开了 model 段(契约 §5 禁止)', line: i + 1 })
163
+ region = 'model'
164
+ regionStart = i + 1
165
+ regionLines = []
166
+ continue
167
+ }
168
+ if (trimmed === WB_MARKERS_V1.modelClose) {
169
+ if (region !== 'model') issues.push({ code: 'model-region-unclosed', detail: 'model 结束标记没有对应的开始标记', line: i + 1 })
170
+ else closeRegion()
171
+ continue
172
+ }
173
+
174
+ // ── 卡片标题 ──
175
+ if (isCardTitle(trimmed)) {
176
+ if (region) { closeRegion(); issues.push({ code: 'user-region-unclosed', detail: '新卡片开始前上一张卡的分区未闭合', line: i + 1 }) }
177
+ cur = { id: '', title: titleTextOf(trimmed), titleLine: i + 1, anchorLine: 0, status: 'current', expectAnchor: true }
178
+ cards.push(cur)
179
+ continue
180
+ }
181
+
182
+ // ── 锚点行(必须在标题行下方**紧跟**的位置) ──
183
+ if (isAnchor(trimmed)) {
184
+ if (!cur) { issues.push({ code: 'anchor-mid-body', detail: '锚点不在任何卡片标题下方', line: i + 1 }); continue }
185
+ if (i + 1 !== cur.titleLine + 1) {
186
+ issues.push({ code: 'anchor-mid-body', detail: '锚点在标题行下方第 ' + (i + 1 - cur.titleLine) + ' 行(契约为紧跟下一行)', line: i + 1 })
187
+ }
188
+ const id = anchorIdOf(trimmed)
189
+ if (seenIds.has(id)) {
190
+ issues.push({ code: 'anchor-duplicate', detail: 'id ' + id + ' 已被第 ' + seenIds.get(id) + ' 行的卡片占用', line: i + 1 })
191
+ } else seenIds.set(id, i + 1)
192
+ cur.id = id
193
+ cur.anchorLine = i + 1
194
+ cur.expectAnchor = false
195
+ continue
196
+ }
197
+
198
+ // ── 状态声明(`status=superseded` 这类;缺省 current,与 isCurrentPre 口径一致) ──
199
+ const sm = /(?:^|\s)status\s*[=:]\s*(current|superseded|retracted)\b/i.exec(trimmed)
200
+ if (sm && cur && !region) cur.status = sm[1].toLowerCase()
201
+
202
+ if (region) regionLines.push(line)
203
+ }
204
+ if (region) {
205
+ issues.push({
206
+ code: region === 'user' ? 'user-region-unclosed' : 'model-region-unclosed',
207
+ detail: '文件结束时分区仍未闭合(起始行 ' + regionStart + ')', line: lines.length,
208
+ })
209
+ }
210
+
211
+ // ── 逐卡检查:缺锚点 / 锚点格式 ──
212
+ if (!cards.length) {
213
+ // 空页面不算"缺卡"事故(首建白板是合法路径);但**有内容却一张卡都没有**要报出来
214
+ if (raw.trim()) issues.push({ code: 'no-card', detail: '页面有内容但没有任何 `### ` 卡片标题', line: 0 })
215
+ } else {
216
+ for (const c of cards) {
217
+ if (!c.id) issues.push({ code: 'anchor-missing', detail: '卡片「' + c.title + '」缺锚点行', line: c.titleLine })
218
+ }
219
+ }
220
+
221
+ const cardIds = cards.map((c) => c.id).filter(Boolean)
222
+ return {
223
+ ok: issues.length === 0,
224
+ version: WB_CONTRACT_VERSION,
225
+ kind,
226
+ cards: cards.map((c) => ({
227
+ id: c.id, title: c.title, status: c.status,
228
+ titleLine: c.titleLine, anchorLine: c.anchorLine,
229
+ hasUserRegion: userRegions.some((u) => u.cardId && u.cardId === c.id),
230
+ hasModelRegion: modelRegions.some((m) => m.cardId && m.cardId === c.id),
231
+ })),
232
+ cardIds,
233
+ userRegions,
234
+ modelRegions,
235
+ issues,
236
+ counts: {
237
+ cards: cards.length,
238
+ anchored: cardIds.length,
239
+ unanchored: cards.length - cardIds.length,
240
+ userRegions: userRegions.length,
241
+ modelRegions: modelRegions.length,
242
+ lines: lines.length,
243
+ },
244
+ }
245
+ }
246
+
247
+ const shortDigest = (s) => createHash('sha256').update(String(s == null ? '' : s)).digest('hex').slice(0, 16)
248
+
249
+ function emptyResult(kind, issues) {
250
+ return {
251
+ ok: false, version: WB_CONTRACT_VERSION, kind,
252
+ cards: [], cardIds: [], userRegions: [], modelRegions: [], issues,
253
+ counts: { cards: 0, anchored: 0, unanchored: 0, userRegions: 0, modelRegions: 0, lines: 0 },
254
+ }
255
+ }
256
+
257
+ /**
258
+ * 从投影里抽出"**必须逐字节保留**的用户区"(供写入门做前后比对)。
259
+ *
260
+ * 只返回**有 cardId 的用户区**:没有 cardId 的用户区无法与卡片对应,
261
+ * 其"是否被保留"无从判定(那属于 lint/格式问题,不是写入门能拦的丢卡问题)。
262
+ *
263
+ * @param {object} projection `parseWhiteboardPre` 的返回值
264
+ * @returns {Array<{key:string, digest:string, chars:number, cardId:string}>}
265
+ */
266
+ export function extractProtectedRegionsPre(projection) {
267
+ const p = projection && typeof projection === 'object' ? projection : {}
268
+ const out = []
269
+ for (const u of Array.isArray(p.userRegions) ? p.userRegions : []) {
270
+ const cardId = String((u && u.cardId) || '')
271
+ if (!cardId) continue
272
+ out.push({
273
+ key: 'user:' + cardId,
274
+ cardId,
275
+ digest: String((u && u.digest) || shortDigest(u && u.text)),
276
+ chars: String((u && u.text) || '').length,
277
+ })
278
+ }
279
+ return out
280
+ }
281
+
282
+ /**
283
+ * 把白板投影适配成写入门要的**规范化投影**(`{beforeIds, afterIds, protectedRegions}`)。
284
+ *
285
+ * @param {object} projection `parseWhiteboardPre` 的返回值
286
+ * @returns {{beforeIds?:string[], afterIds?:string[], protectedRegions:Array, cards:Array, issues:Array}}
287
+ */
288
+ export function toMutationProjectionPre(projection) {
289
+ const p = projection && typeof projection === 'object' ? projection : {}
290
+ return {
291
+ afterIds: Array.isArray(p.cardIds) ? p.cardIds.slice() : [],
292
+ protectedRegions: extractProtectedRegionsPre(p),
293
+ cards: Array.isArray(p.cards) ? p.cards : [],
294
+ issues: Array.isArray(p.issues) ? p.issues : [],
295
+ }
296
+ }
297
+
298
+ // ═══════════════════════════════════════════════════════════════════════════
299
+ // 判据门(criteria gate)—— 账本用 H1–H4/S1–S4;PLAN 用 P-H1/P-H2/P-S1
300
+ //
301
+ // ⚠️ **判据不能张冠李戴**(v2 修正,务必遵守;`ROUND3 §2.2` 把我方原写法判为实质错误):
302
+ // H1–H4/S1–S4 是**交接账本**的判据;PLAN 是自由全貌文档(节名不固定,P7 老化按标题分类),
303
+ // 其判据**刻意保持最弱** = P-H1/P-H2/P-S1。把账本判据套给 PLAN 会误拒合法白板。
304
+ //
305
+ // **与共同保护的关系**(ROUND3 §3.7 第 4 条):本判据门是**可选质量门**,
306
+ // `criteriaGate=false` 只退掉它;**丢卡 / 用户区 / 重复 id 三条保护在
307
+ // `memory-mutation.js` 里,无条件生效**,任何开关都绕不过。
308
+ // ═══════════════════════════════════════════════════════════════════════════
309
+
310
+ export const CRITERIA_GATE_VERSION = 'wb_criteria_v1'
311
+
312
+ /** 账本四段标题(逐字匹配;与 `handoff-anchor.js` 的权重表同源,不另写一份)。 */
313
+ export const HANDOFF_REQUIRED_SECTIONS_V1 = Object.freeze(
314
+ HANDOFF_LEDGER_SECTION_WEIGHTS_V1.map((x) => x.title),
315
+ )
316
+
317
+ /** 占位符黑名单(H3):段 body 只含这些即视为未填写。 */
318
+ export const CRITERIA_PLACEHOLDERS_V1 = Object.freeze(['(待补充)', '(待补充)', 'todo', '同上', '略', 'n/a', '待补充'])
319
+
320
+ /** 账本/白板判据的阈值(与 `sanitizeForWrite` 上限同源,先于它执行以免双重截断语义混乱)。 */
321
+ export const CRITERIA_LIMITS_V1 = Object.freeze({
322
+ handoffMaxChars: 8000,
323
+ planMaxChars: 200000,
324
+ sectionMinChars: 20,
325
+ sectionMaxLines: 5,
326
+ })
327
+
328
+ const PLACEHOLDER_SET = new Set(CRITERIA_PLACEHOLDERS_V1.map((x) => x.toLowerCase()))
329
+
330
+ /** 段 body 的"实质内容"行(去空行、去纯占位行)。 */
331
+ function bodyRealLines(body) {
332
+ return (Array.isArray(body) ? body : [])
333
+ .map((l) => String(l == null ? '' : l).trim())
334
+ .filter((l) => l && !PLACEHOLDER_SET.has(l.toLowerCase()))
335
+ }
336
+
337
+ /**
338
+ * 交接账本判据(H1–H4 硬;S1–S4 软)。**纯函数、零 IO**。
339
+ *
340
+ * @param {string} text 账本全文(写入函数会自带 `# 交接账本 · <日期> <时间>` 标题行,故解析 preamble 非空)
341
+ * @returns {{ok:boolean, version:string, target:'handoff', hardPass:boolean,
342
+ * hard:Array, soft:Array, report:object}}
343
+ */
344
+ export function checkHandoffCriteriaPre(text) {
345
+ const src = typeof text === 'string' ? text : ''
346
+ const parsed = parseHandoffLedgerPre(src)
347
+ const hard = []
348
+ const soft = []
349
+
350
+ // H1 四段标题齐全且逐字匹配
351
+ const gotTitles = parsed ? parsed.sections.map((s) => String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()) : []
352
+ const missing = HANDOFF_REQUIRED_SECTIONS_V1.filter((t) => !gotTitles.includes(t))
353
+ hard.push({
354
+ id: 'H1', pass: missing.length === 0, missing: missing.slice(),
355
+ detail: missing.length
356
+ ? '缺少或标题不逐字匹配的四段标题:' + missing.join('、') + '(标题错会让权重化截断失效并让注入端解析失败)'
357
+ : '四段标题齐全且逐字匹配',
358
+ })
359
+
360
+ // H2 每段 body 非空(≥1 非空行且合计 ≥20 字符)
361
+ const thinSections = []
362
+ if (parsed) {
363
+ for (const s of parsed.sections) {
364
+ const name = String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()
365
+ if (!HANDOFF_REQUIRED_SECTIONS_V1.includes(name)) continue
366
+ const real = bodyRealLines(s.body)
367
+ const chars = real.join('').length
368
+ if (!real.length || chars < CRITERIA_LIMITS_V1.sectionMinChars) thinSections.push(name + '(' + chars + ' 字符)')
369
+ }
370
+ } else {
371
+ thinSections.push('(无法解析出任何段)')
372
+ }
373
+ hard.push({
374
+ id: 'H2', pass: thinSections.length === 0, missing: thinSections.slice(),
375
+ detail: thinSections.length
376
+ ? '这些段为空或少于 ' + CRITERIA_LIMITS_V1.sectionMinChars + ' 字符:' + thinSections.join('、')
377
+ : '四段 body 均非空且达到最小长度',
378
+ })
379
+
380
+ // H3 无占位符行
381
+ const placeholderHits = []
382
+ if (parsed) {
383
+ for (const s of parsed.sections) {
384
+ const name = String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()
385
+ const real = bodyRealLines(s.body)
386
+ const allBody = (Array.isArray(s.body) ? s.body : []).map((l) => String(l == null ? '' : l).trim()).filter(Boolean)
387
+ if (allBody.length && !real.length) placeholderHits.push(name)
388
+ }
389
+ }
390
+ hard.push({
391
+ id: 'H3', pass: placeholderHits.length === 0, missing: placeholderHits.slice(),
392
+ detail: placeholderHits.length
393
+ ? '这些段的正文只有占位符(' + CRITERIA_PLACEHOLDERS_V1.slice(0, 4).join('/') + ' 等):' + placeholderHits.join('、')
394
+ : '无纯占位符段',
395
+ })
396
+
397
+ // H4 总长 ≤ 8000
398
+ hard.push({
399
+ id: 'H4', pass: src.length <= CRITERIA_LIMITS_V1.handoffMaxChars, missing: [],
400
+ detail: '总长 ' + src.length + ' 字符(上限 ' + CRITERIA_LIMITS_V1.handoffMaxChars + ')',
401
+ })
402
+
403
+ // S1 每段 ≤5 行(软·警告;prompt 纪律既有约定)
404
+ const longSections = []
405
+ if (parsed) {
406
+ for (const s of parsed.sections) {
407
+ const name = String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim()
408
+ if (!HANDOFF_REQUIRED_SECTIONS_V1.includes(name)) continue
409
+ const n = bodyRealLines(s.body).length
410
+ if (n > CRITERIA_LIMITS_V1.sectionMaxLines) longSections.push(name + '(' + n + ' 行)')
411
+ }
412
+ }
413
+ soft.push({ id: 'S1', pass: longSections.length === 0, detail: longSections.length ? '超过 5 行的段:' + longSections.join('、') : '各段均 ≤5 行' })
414
+
415
+ // S2「已试方案与失败原因」写成「方案→失败原因」且保留报错关键词
416
+ const failSec = parsed ? parsed.sections.find((s) => String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim() === '已试方案与失败原因') : null
417
+ const failBody = failSec ? bodyRealLines(failSec.body).join('\n') : ''
418
+ const hexArrow = /→|->|⇒/.test(failBody)
419
+ const errWord = /失败|报错|错误|回滚|error|fail|bug/i.test(failBody)
420
+ soft.push({
421
+ id: 'S2', pass: hexArrow && errWord,
422
+ detail: (hexArrow ? '' : '缺「方案→失败原因」箭头;') + (errWord ? '' : '缺关键报错词;') || '失败项格式良好',
423
+ })
424
+
425
+ // S3「进度与下一步」含可执行特征(路径分隔符 / 反引号代码 / 命令动词)
426
+ const nextSec = parsed ? parsed.sections.find((s) => String(s.title).replace(/\r$/, '').replace(/^##\s+/, '').trim() === '进度与下一步') : null
427
+ const nextBody = nextSec ? bodyRealLines(nextSec.body).join('\n') : ''
428
+ const execish = /[\\/][\w.-]+|`[^`]+`|\bnode\b|\bnpm\b|\bgit\b|\brun\b|执行|运行|命令/i.test(nextBody)
429
+ soft.push({ id: 'S3', pass: execish, detail: execish ? '下一步含可执行特征' : '下一步看不出可直接执行的第一步(软提示)' })
430
+
431
+ // S4 无临时信息(低置信启发,仅 diag)
432
+ const tempish = /临时路径|tmp\\|\\\\tmp|搜索结果[::]|console\.log\(/i.test(src)
433
+ soft.push({ id: 'S4', pass: !tempish, detail: tempish ? '疑似含临时信息(低置信,仅诊断)' : '未见明显临时信息' })
434
+
435
+ return {
436
+ ok: hard.every((h) => h.pass), version: CRITERIA_GATE_VERSION, target: 'handoff',
437
+ hardPass: hard.every((h) => h.pass), hard, soft,
438
+ report: { chars: src.length, sections: gotTitles.length, requiredSections: HANDOFF_REQUIRED_SECTIONS_V1.length },
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 白板 PLAN 判据(P-H1/P-H2 硬;P-S1 软)。**刻意保持最弱**(v2 修正)。
444
+ *
445
+ * P-H1 至少一个非空 `## ` 顶层节(与 P7 老化的节切分逻辑一致)—— 防"全部节被老化走"的空白板;
446
+ * P-H2 ≤ 200000 字符;P-S1 含至少一处前瞻内容(软)。
447
+ *
448
+ * @param {string} text 白板全文
449
+ * @param {object} [projection] 已有的 `parseWhiteboardPre` 结果(省略则只看文本,不看卡片)
450
+ */
451
+ export function checkPlanCriteriaPre(text, projection) {
452
+ const src = typeof text === 'string' ? text : ''
453
+ const hard = []
454
+ const soft = []
455
+ // 与 P7 老化同一口径:按 `## ` 切顶层节(`### ` 不算顶层节)
456
+ const sections = src.split(/(?=^## )/m).filter((s) => /^## /.test(s))
457
+ const nonEmpty = sections.filter((s) => {
458
+ const body = s.split('\n').slice(1).join('\n').trim()
459
+ return body.length >= CRITERIA_LIMITS_V1.sectionMinChars
460
+ })
461
+ hard.push({
462
+ id: 'P-H1', pass: nonEmpty.length >= 1, missing: nonEmpty.length ? [] : ['(无非空 ## 顶层节)'],
463
+ detail: nonEmpty.length
464
+ ? '有 ' + nonEmpty.length + ' 个非空顶层节(共 ' + sections.length + ' 个)'
465
+ : '没有任何非空 `## ` 顶层节(' + CRITERIA_LIMITS_V1.sectionMinChars + ' 字符以上)—— 这是"白板被整篇覆盖成骨架/空白板"的形态',
466
+ })
467
+ hard.push({
468
+ id: 'P-H2', pass: src.length <= CRITERIA_LIMITS_V1.planMaxChars, missing: [],
469
+ detail: '总长 ' + src.length + ' 字符(上限 ' + CRITERIA_LIMITS_V1.planMaxChars + ')',
470
+ })
471
+ const forward = /下一步|待办|计划|todo/i.test(src)
472
+ soft.push({ id: 'P-S1', pass: forward, detail: forward ? '含前瞻内容' : '未见前瞻内容(下一步/待办/计划)—— 软提示,不拦截' })
473
+
474
+ // 卡片情况只作**报告**,不作 PLAN 硬判据(P0 既有白板没有锚点,硬判会立刻误拒所有现存白板)。
475
+ // 锚点完备性属白板线 P1/P2,不在此拦截。
476
+ const cards = projection && Array.isArray(projection.cards) ? projection.cards : null
477
+ return {
478
+ ok: hard.every((h) => h.pass), version: CRITERIA_GATE_VERSION, target: 'plan',
479
+ hardPass: hard.every((h) => h.pass), hard, soft,
480
+ report: {
481
+ chars: src.length, sections: sections.length, nonEmptySections: nonEmpty.length,
482
+ cards: cards ? cards.length : null, anchored: projection ? (projection.cardIds || []).length : null,
483
+ },
484
+ }
485
+ }
486
+
487
+ /** 判据报告 → 给模型的可执行拒绝文案(缺什么、哪段空、占位符原文,逐条列出)。 */
488
+ export function criteriaRefusalTextPre(res) {
489
+ const r = res && typeof res === 'object' ? res : {}
490
+ const failed = (Array.isArray(r.hard) ? r.hard : []).filter((h) => !h.pass)
491
+ if (!failed.length) return ''
492
+ return '写入被判据门拦截(' + failed.map((h) => h.id).join('/') + '),原文件未改动:\n'
493
+ + failed.map((h) => '· [' + h.id + '] ' + h.detail).join('\n')
494
+ + '\n请补齐后重试。'
495
+ }