@a9i5k4/dsh-auto-memory 2.5.2 → 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 +880 -68
  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 +88 -2
  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
@@ -27,9 +27,19 @@ export function parseModelWindowsPre(text) {
27
27
  // provider 段(block 风格,2 空格缩进的裸键,如 `opencode-go:`);flow 风格段名同样以冒号结尾但缩进更深
28
28
  const pm = line.match(/^ {2}([A-Za-z0-9_.-]+):\s*$/)
29
29
  if (pm) { currentProvider = pm[1]; currentId = null; continue }
30
- // 模型 id:兼容 block(`- id: x`)与 flow(`id: x,`)两种写法,允许行尾逗号
31
- const im = line.match(/^\s*(?:-\s*)?id:\s*([A-Za-z0-9._:-]+?)\s*,?\s*$/)
30
+ // 模型 id:兼容 block(`- id: x`)与 flow(`id: x,`)两种写法,允许行尾逗号。
31
+ // 2026-09-14 移植 PR #31(上游修复,作者 Minervaowl7):字符类须含 `/` ——
32
+ // provider 前缀式 id(`deepseek/deepseek-v4.1-flash`、`z-ai/glm-5.3-flash`、`meituan/LongCat-2.0:free`)
33
+ // 是常见形态;旧字符类不含斜杠 → 整条 id 行匹配失败,其 contextWindow 被**静默丢弃**,
34
+ // 窗口退化为 fallback 131072。实测:真实窗口 1,000,000 的会话被按 131,072 当分母,
35
+ // **水位放大 7.63 倍(12.8% 显示成 98%)**,未达 0.75 阈值即误弹「接续到新会话」确认卡。
36
+ const im = line.match(/^\s*(?:-\s*)?id:\s*([A-Za-z0-9._:/-]+?)\s*,?\s*$/)
32
37
  if (im) { currentId = im[1]; if (byModel[currentId] === undefined) byModel[currentId] = 0; continue }
38
+ // 2026-09-14 补齐 PR #31 的第二半:上一轮只移植了「字符类须含 `/`」那一半,漏了本条。
39
+ // id 行存在但格式仍不被识别(带引号/含空格/中文等):重置 currentId,避免紧随其后的
40
+ // contextWindow 被记到上一条模型头上 —— 静默错配比"读不到"更危险:读不到只退化为 fallback,
41
+ // 错配会给出**偏小**的窗口,使水位漏报。
42
+ if (/^\s*(?:-\s*)?id:/.test(line)) { currentId = null; continue }
33
43
  // 窗口:允许行尾逗号(flow 风格)
34
44
  const cm = line.match(/^\s*contextWindow:\s*([0-9]+)\s*,?\s*$/)
35
45
  if (cm && currentId) {
@@ -175,3 +185,79 @@ export function findOfficialContextWindowPre(events, maxScan = 0) {
175
185
  }
176
186
  return 0
177
187
  }
188
+
189
+ /**
190
+ * 判断「会话模型扫描结果」缓存可否复用(2026-09-14)。
191
+ *
192
+ * checkWaterLevel 把事件扫描结果按会话缓存 5 分钟,但**空结果也被一并缓存**:
193
+ * `if (cached && cached.sid === sid && now - cached.at < 300000 && cached.info) { 复用 }`
194
+ * `cached.info` 恒为对象(即使内容为空) ⇒ 空结果照样命中,并被锁死 5 分钟。
195
+ *
196
+ * 而 `request/header` / `request/context` 是**发起请求时**才追加的事件 —— 实测同一轮内
197
+ * `turn/start`(seq=4) → `request/context`(seq=13) 仅相隔 58ms,而首轮 `agent/pre-step`
198
+ * 的测量早于这两条(step/start 在 request/context 之前约 21ms)。所以首次扫描必然扫不到
199
+ * `request/context`:只能从 `request/header` 拿到 provider/model,`contextWindow` 为 0。
200
+ * 该结果随后被锁死 5 分钟,这 5 分钟里 `official-context`(路由权威口径)始终不可用,
201
+ * 窗口只能靠 settings.yaml 查表;若该模型在 settings.yaml 里查不到(新模型 / 切到未配置的
202
+ * provider),窗口即退化为 fallback 131072,水位被系统性放大。
203
+ *
204
+ * 复用规则:
205
+ * - **非空**(contextWindow > 0):TTL 内直接复用 —— `request/context` 只在会话开头追加、值稳定,
206
+ * 且扫描是 O(n),长会话理应避免重复全量扫描。
207
+ * - **空**:仅在**事件数未增长**时复用 —— 事件数增长意味着可能有新事件写入,值得重扫一次;
208
+ * 未增长则复用,不为每个 pre-step 付出一次 O(n) 扫描。
209
+ *
210
+ * 兼容性:`cached.events` 为本次新增字段,旧缓存对象取到 undefined ⇒ 与 eventsLen 不相等 ⇒
211
+ * 不复用、走重扫 —— 安全降级。
212
+ *
213
+ * @param {{sid:string, at:number, info:object, events:number}|null|undefined} cached - 上次缓存(cached.events = 扫描时的事件数)。
214
+ * @param {string} sid - 当前会话 id。
215
+ * @param {number} eventsLen - 当前事件数组长度。
216
+ * @param {number} now - 当前时间戳(ms)。
217
+ * @param {number} [ttlMs=300000] - 缓存有效期。
218
+ * @returns {boolean} true 表示可复用 cached.info。
219
+ */
220
+ export function reusableWindowCachePre(cached, sid, eventsLen, now, ttlMs = 300000) {
221
+ if (!cached || !sid) return false
222
+ if (cached.sid !== sid) return false
223
+ if (!(now - Number(cached.at || 0) < ttlMs)) return false
224
+ const info = cached.info
225
+ if (!info) return false
226
+ if (Number(info.contextWindow) > 0) return true
227
+ return Number(cached.events) === Number(eventsLen)
228
+ }
229
+
230
+ /**
231
+ * 判断是否具备「按水位比例触发自动接续」的资格(2026-09-14)。
232
+ *
233
+ * 背景:窗口来源优先级为 手动覆盖 > 官方 request/context > settings.yaml 按「会话真实模型」查表
234
+ * > fallback 131072。而「会话真实模型」只能从会话事件 `request/header` / `request/context` 读取 ——
235
+ * DSH 的 sessionApi 只提供 `selectModel`(只写不读),没有任何查询会话当前模型的接口。
236
+ *
237
+ * 这两个事件都是**发起请求时**才追加的:实测首轮 `turn/start` → `request/header` 之间不存在任何
238
+ * 模型信息,即在 **新会话首轮 `agent/pre-step`** 的测量点上,`findSessionModelPre` 返回
239
+ * `{provider:'',model:'',contextWindow:0,maxTokens:0}` —— **全空**。此时窗口与预留额度只能用
240
+ * settings.yaml 的 `agent-default-model` 推算,而它与会话实际模型可以完全不同
241
+ * (实测同一台机器上 `agent-default-model` 是 `command-code/deepseek-v4.1-flash`,
242
+ * 而某个会话实际跑的是 `deepseek-official/deepseek-flash`,连 `maxTokens` 都不一致)。
243
+ *
244
+ * 用推算出来的分母按比例触发「建新会话 + 注入交接材料 + 切走窗口」这类重动作,风险不对称:
245
+ * 分母偏小会让水位虚高数倍(实测 1,000,000 被当 131,072,放大 7.63 倍),未达阈值即误弹接续卡。
246
+ * 因此模型未知时**只放行硬信号**(`compaction` / `overflow` / 撞硬墙 —— 均为真实事件,不依赖窗口估算);
247
+ * 纯比例判据推迟到轮末再判 —— 届时 `request/header` 已写入,模型已知,判定链条完整。
248
+ *
249
+ * `wl.modelKnown !== true` 采用 fail-closed(2026-09-14 审查修正):字段缺失(undefined/null/0)一律
250
+ * 视为「未知」,只放行硬信号。原先 `=== false` 显式判等本意是让未传字段的旧调用点行为不变,但
251
+ * checkWaterLevel 存在早退路径(handoff 关闭 / win<=0 / 测量中途异常),此时 rt.waterLevelModelKnown
252
+ * 为 undefined → 闸被静默绕过(变异验证:直调 {ratio:0.99, modelKnown:undefined, hard:false} 放行)。
253
+ * fail-closed 后该路径收口为「不按比例 arm」—— 漏判优于误判,与本函数的整体取舍一致;两个 in-repo
254
+ * 调用点都以 `!!(...)` 写入真布尔,正常测量路径行为不变。
255
+ *
256
+ * @param {{ratio:number, modelKnown?:boolean, hard?:boolean}|null|undefined} wl - 水位快照。
257
+ * @returns {boolean} true 表示允许按比例 arm。
258
+ */
259
+ export function shouldArmAutoContinuePre(wl) {
260
+ if (!wl) return false
261
+ if (wl.modelKnown !== true && !wl.hard) return false
262
+ return true
263
+ }
@@ -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
+ }