@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,246 @@
1
+ /**
2
+ * 记忆写入保护门(memory_mutation_v1)—— 3.0 主体拥有「共同提交与保护入口」。
3
+ *
4
+ * 2026-09-14 建立(P0)。**边界(总纲 §0.5 / ROUND3 §3.1 定案,必须遵守)**:
5
+ * - **本模块**只接收**规范化投影**:`{beforeIds, afterIds, protectedRegions, changes}`。
6
+ * 它**不自行解释图格式** —— 不知道 `### ` 是什么、不知道 `<!-- user -->` 是什么。
7
+ * - **格式由适配器提供**:白板走 `lib/wb-contract.js:parseWhiteboardPre` → `toMutationProjectionPre`;
8
+ * 账本/笔记等其他目标各给各的投影。**格式只维护一份**。
9
+ *
10
+ * **为什么要这个门**(不是一个好想法,是事故根因):
11
+ * `WB-FORMAT-CONVENTION.md` §4 的写入门只做一件事 —— **重写前后比对卡片集合**:
12
+ * 允许移动、改状态、改正文、加卡;**不允许卡片凭空消失**;要消失必须显式移入 `archived` 并留痕。
13
+ * 实际事故是「白板被整篇覆盖成骨架」(规范已批准、代码从未实现)。
14
+ * 2026-09-14 实测缺口:`PLAN.md` 一个锚点、一个分区标记都没有。
15
+ *
16
+ * **三条保护**(每条都有能红断言,见 `tests/smoke/smoke-test-t0-8-mutation-gate-pre.mjs`):
17
+ * M1 **丢卡保护**:`beforeIds` 里有、`afterIds` 里没有的 id,必须出现在 `archived` 里(显式归档 + 留痕);
18
+ * 否则**拒绝写入**并报出差异清单(不是静默接受)。
19
+ * M2 **用户区保护**(B4 预授权默认值:每卡分「模型维护区 / 用户备注区」):
20
+ * `protectedRegions` 的 digest 必须原样出现在 after 侧 —— 模型整篇重写**必须原样带回**用户段。
21
+ * M3 **重复 id 保护**:after 侧同一 id 出现两次即拒绝(契约 §2 禁止复用同一 id 指两个卡片)。
22
+ *
23
+ * **fail-open 不得绕过保护**(ROUND3 §3.7 第 4 条,总纲 v2 明确):
24
+ * `criteriaGate=false` 与"骨架 fail-soft"**只能退掉可选质量门**(H1–H4/S1–S4/P-H1/P-H2 那类
25
+ * 判据),**不得**跳过丢卡、用户区、版本、状态保护。本模块的 `strict` 参数**只影响
26
+ * `changes` 类软项**(例如"本次是骨架写入"的提示),**对 M1/M2/M3 无任何影响** ——
27
+ * 这三条是**无条件**的。测试 `T0-8C` 专门锁这一点。
28
+ *
29
+ * S9 合规:零 IO、零外部依赖(只用 node:crypto 做摘要)、纯函数、无网络/无 LLM/无子进程/无 await。
30
+ * UTF-8 无 BOM。
31
+ */
32
+ import { createHash } from 'node:crypto'
33
+
34
+ export const MEMORY_MUTATION_VERSION = 'memory_mutation_v1'
35
+
36
+ /** 拒绝/提示的原因码 → 可读中文。 */
37
+ export const MUTATION_REASONS_V1 = Object.freeze({
38
+ 'card-disappeared': '卡片消失且无归档记录(契约 §4:不允许凭空消失)',
39
+ 'protected-region-lost': '受保护区域(用户备注区)未被原样带回',
40
+ 'protected-region-modified': '受保护区域被改动(必须逐字节保留)',
41
+ 'duplicate-id': '同一 id 在写入后出现两次(契约 §2 禁止)',
42
+ 'invalid-projection': '规范化投影形状非法(缺 beforeIds/afterIds)',
43
+ 'not-object': '传入的不是对象',
44
+ })
45
+
46
+ /** 原因码 → 可读中文(未知码原样返回)。 */
47
+ export function describeMutationReasonPre(code) {
48
+ const k = String(code == null ? '' : code)
49
+ return MUTATION_REASONS_V1[k] || k || '未知原因'
50
+ }
51
+
52
+ const asStringArray = (v) => (Array.isArray(v) ? v.map((x) => String(x == null ? '' : x)).filter(Boolean) : null)
53
+ const sha = (s) => createHash('sha256').update(String(s == null ? '' : s)).digest('hex').slice(0, 16)
54
+
55
+ /**
56
+ * 共同提交与保护入口(**纯函数**)。
57
+ *
58
+ * @param {object} input
59
+ * @param {string[]} input.beforeIds 写入前该目标拥有的卡片 id 集合(规范化投影;首建传 `[]`)
60
+ * @param {string[]} input.afterIds 写入后将要拥有的卡片 id 集合
61
+ * @param {Array<{key:string,digest:string,chars?:number}>} [input.protectedRegions]
62
+ * 受保护区域的摘要清单(前后比对用;通常来自适配器的 `extractProtectedRegionsPre`)
63
+ * @param {Array<{key:string,digest:string}>} [input.afterProtectedRegions]
64
+ * 写入后同区域的实际摘要。**省略时的语义必须明确**:视为"无法证明被保留" ⇒ **拒绝**(fail closed),
65
+ * 而不是"默认通过"。这是因为"忘了传"和"真的丢了"在保护语义上必须同样处理。
66
+ * @param {string[]} [input.archivedIds] 显式归档的 id(进入归档集合 + 留痕 = 合法"消失")
67
+ * @param {object} [input.changes] 供报告使用的变更描述(**不参与保护判定**)
68
+ * @param {string} [input.target] 'plan' | 'handoff' | 'note' | 'other'(仅用于报告)
69
+ * @param {boolean} [input.strict=true] 只影响软提示(如"骨架写入");**不影响 M1/M2/M3**
70
+ * @returns {{ok:boolean, version:string, target:string, gate:string,
71
+ * hard:Array<{id:string, pass:boolean, missing:Array, detail:string}>,
72
+ * soft:Array<{id:string, pass:boolean, detail:string}>,
73
+ * report:{disappeared:string[], archived:string[], unarchived:string[],
74
+ * protectedChecked:number, protectedOk:number, duplicateIds:string[]}}}
75
+ */
76
+ export function validateMutationBoundaryPre(input = {}) {
77
+ const o = input && typeof input === 'object' ? input : null
78
+ const target = String((o && o.target) || 'other')
79
+ const strict = !o || o.strict !== false
80
+ const hard = []
81
+ const soft = []
82
+
83
+ if (!o) {
84
+ hard.push({ id: 'M0', pass: false, missing: ['projection'], detail: describeMutationReasonPre('not-object') })
85
+ return report(false, target, hard, soft, emptyReport())
86
+ }
87
+
88
+ const beforeIds = asStringArray(o.beforeIds)
89
+ const afterIds = asStringArray(o.afterIds)
90
+ if (!beforeIds || !afterIds) {
91
+ hard.push({ id: 'M0', pass: false, missing: ['beforeIds', 'afterIds'].filter((k) => !Array.isArray(o[k])), detail: describeMutationReasonPre('invalid-projection') })
92
+ return report(false, target, hard, soft, emptyReport())
93
+ }
94
+
95
+ const archived = asStringArray(o.archivedIds) || []
96
+ const afterSet = new Set(afterIds)
97
+ const beforeSet = new Set(beforeIds)
98
+
99
+ // ── M1 丢卡保护(无条件;`strict`/质量门开关都绕不过) ──
100
+ const disappeared = beforeIds.filter((id) => !afterSet.has(id))
101
+ const unarchived = disappeared.filter((id) => !archived.includes(id))
102
+ hard.push({
103
+ id: 'M1',
104
+ pass: unarchived.length === 0,
105
+ missing: unarchived.slice(),
106
+ detail: unarchived.length
107
+ ? '这些卡片消失了且没有归档记录:' + unarchived.slice(0, 8).join('、')
108
+ + (unarchived.length > 8 ? ' 等 ' + unarchived.length + ' 张' : '')
109
+ + '。契约 §4:要"消失"必须显式移入归档集合并留痕(时间 + 原因)。'
110
+ : '前后比对无未归档的丢卡(消失 ' + disappeared.length + ' 张,其中已归档 ' + (disappeared.length - unarchived.length) + ' 张)',
111
+ })
112
+ const newlyArchived = archived.filter((id) => !beforeSet.has(id))
113
+
114
+ // ── M3 重复 id 保护(契约 §2:禁止复用同一个 id 指两个卡片) ──
115
+ const seen = new Set()
116
+ const duplicateIds = []
117
+ for (const id of afterIds) {
118
+ if (seen.has(id)) { if (!duplicateIds.includes(id)) duplicateIds.push(id) }
119
+ else seen.add(id)
120
+ }
121
+ hard.push({
122
+ id: 'M3',
123
+ pass: duplicateIds.length === 0,
124
+ missing: duplicateIds.slice(),
125
+ detail: duplicateIds.length
126
+ ? '写入后同一 id 出现两次:' + duplicateIds.slice(0, 6).join('、') + '。契约 §2 禁止复用同一 id 指两个卡片。'
127
+ : '写入后无重复 id(' + afterIds.length + ' 张)',
128
+ })
129
+
130
+ // ── M2 用户区保护(无条件;**省略 afterProtectedRegions = fail closed**) ──
131
+ const prot = Array.isArray(o.protectedRegions) ? o.protectedRegions.filter(Boolean) : []
132
+ const afterProtRaw = o.afterProtectedRegions
133
+ const afterProtMissing = !Array.isArray(afterProtRaw)
134
+ const afterProt = afterProtMissing ? [] : afterProtRaw.filter(Boolean)
135
+ const afterByKey = new Map(afterProt.map((r) => [String((r && r.key) || ''), String((r && r.digest) || '')]))
136
+ const lost = []
137
+ const modified = []
138
+ for (const r of prot) {
139
+ const key = String((r && r.key) || '')
140
+ const want = String((r && r.digest) || '')
141
+ if (!afterByKey.has(key)) { lost.push(key); continue }
142
+ if (afterByKey.get(key) !== want) modified.push(key)
143
+ }
144
+ const m2pass = !afterProtMissing && lost.length === 0 && modified.length === 0
145
+ hard.push({
146
+ id: 'M2',
147
+ pass: m2pass,
148
+ missing: lost.concat(modified),
149
+ detail: afterProtMissing
150
+ ? '未提供写入后的受保护区域摘要(afterProtectedRegions)⇒ 无法证明用户备注区被保留,按 fail closed 拒绝。'
151
+ + '("忘了传"与"真的丢了"在保护语义上必须同样处理。)'
152
+ : (m2pass
153
+ ? '受保护区域 ' + prot.length + ' 处全部原样保留(逐摘要比对)'
154
+ : '受保护区域未原样保留:丢失 ' + lost.length + ' 处、被改动 ' + modified.length + ' 处'
155
+ + (lost.concat(modified).length ? '(' + lost.concat(modified).slice(0, 6).join('、') + ')' : '')
156
+ + '。契约 §5:模型整篇重写必须原样带回用户段。'),
157
+ })
158
+
159
+ // ── 软项:只做提示,不拦截(`strict=false` 只影响这里) ──
160
+ const newCards = afterIds.filter((id) => !beforeSet.has(id))
161
+ soft.push({
162
+ id: 'S-cards',
163
+ pass: true,
164
+ detail: '新增 ' + newCards.length + ' 张 · 消失 ' + disappeared.length + ' 张(已归档 ' + newlyArchived.length + ' 张)'
165
+ + ' · 保留 ' + afterIds.filter((id) => beforeSet.has(id)).length + ' 张',
166
+ })
167
+ if (strict && beforeIds.length > 0 && afterIds.length === 0) {
168
+ soft.push({ id: 'S-empty', pass: false, detail: '写入后卡片集合为空(整篇被覆盖成空白板的典型形态)—— 已由 M1 硬拦,此处仅提示' })
169
+ }
170
+ if (!strict) {
171
+ soft.push({ id: 'S-strict-off', pass: true, detail: 'strict=false:已退掉可选质量门(不影响 M1/M2/M3 三条共同保护)' })
172
+ }
173
+
174
+ return report(hard.every((h) => h.pass), target, hard, soft, {
175
+ disappeared, archived, unarchived, newlyArchived,
176
+ protectedChecked: prot.length, protectedOk: prot.length - lost.length - modified.length,
177
+ protectedLost: lost, protectedModified: modified,
178
+ duplicateIds,
179
+ })
180
+ }
181
+
182
+ function emptyReport() {
183
+ return {
184
+ disappeared: [], archived: [], unarchived: [], newlyArchived: [],
185
+ protectedChecked: 0, protectedOk: 0, protectedLost: [], protectedModified: [], duplicateIds: [],
186
+ }
187
+ }
188
+
189
+ function report(hardPass, target, hard, soft, r) {
190
+ return {
191
+ version: MEMORY_MUTATION_VERSION,
192
+ target,
193
+ // gate 名与 WB-GRAPH §2.4 的既有约定一致,便于工具层按 `gate==='criteria'`/`'mutation'` 分支
194
+ gate: 'mutation',
195
+ ok: hardPass,
196
+ hardPass,
197
+ hard,
198
+ soft,
199
+ report: r,
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 把校验报告压成**给模型看的可执行拒绝文案**(不是给作者看的日志)。
205
+ *
206
+ * 为什么单独一个函数:拒绝文案要能直接驱动"改写后重试"。缺什么、哪张卡、哪个区,
207
+ * 必须逐条列出 —— 模型拿到"写入失败"四个字是修不回来的。
208
+ *
209
+ * @param {object} res `validateMutationBoundaryPre` 的返回值
210
+ * @returns {string}
211
+ */
212
+ export function mutationRefusalTextPre(res) {
213
+ const r = res && typeof res === 'object' ? res : {}
214
+ const failed = (Array.isArray(r.hard) ? r.hard : []).filter((h) => !h.pass)
215
+ if (!failed.length) return ''
216
+ const lines = failed.map((h) => '· [' + h.id + '] ' + h.detail)
217
+ return '写入被记忆保护门拦截(' + failed.map((h) => h.id).join('/') + '),原文件未改动:\n' + lines.join('\n')
218
+ + '\n请修正后重试:消失的卡片要么原样保留,要么显式写入归档集合(并在归档文件里留痕);'
219
+ + '每张卡的用户备注区必须原样带回、逐字节不变。'
220
+ }
221
+
222
+ /**
223
+ * 便捷入口:把"before 投影 + after 投影"直接对照(两端都由适配器产出)。
224
+ *
225
+ * @param {{before:object, after:object, archivedIds?:string[], target?:string, strict?:boolean}} input
226
+ */
227
+ export function validateProjectionPairPre(input = {}) {
228
+ const o = input && typeof input === 'object' ? input : {}
229
+ const before = o.before && typeof o.before === 'object' ? o.before : null
230
+ const after = o.after && typeof o.after === 'object' ? o.after : null
231
+ return validateMutationBoundaryPre({
232
+ target: o.target,
233
+ strict: o.strict,
234
+ beforeIds: before ? before.cardIds || before.ids || [] : null,
235
+ afterIds: after ? after.cardIds || after.ids || [] : null,
236
+ protectedRegions: before ? before.protectedRegions || [] : [],
237
+ afterProtectedRegions: after ? after.protectedRegions || [] : undefined,
238
+ archivedIds: o.archivedIds || [],
239
+ changes: o.changes,
240
+ })
241
+ }
242
+
243
+ /** 受保护区域摘要(给适配器与测试共用的唯一算法:只取 16 位十六进制,够比对、不泄露原文)。 */
244
+ export function protectedRegionDigestPre(text) {
245
+ return sha(text)
246
+ }
@@ -17,8 +17,10 @@ import { createHash, randomUUID } from 'node:crypto'
17
17
  import { promises as fsDefault } from 'node:fs'
18
18
  import {
19
19
  parseAnchors, buildSidecar, parseSidecar, newMemoryId, MEMORY_ID_RE, ANCHOR_PREFIX, detectNewline,
20
+ MARKER_OPEN, checkReservedSyntaxInContent,
20
21
  } from './memory-anchor.js'
21
22
  import { INDEX_MAX_FILE_BYTES } from './memory-index.js'
23
+ import { retryRename } from './fs-retry.js'
22
24
 
23
25
  function sha256Hex(buf) {
24
26
  return createHash('sha256').update(buf).digest('hex')
@@ -40,6 +42,28 @@ function markerBuf(memoryId, nl) {
40
42
  return Buffer.from('<!-- memory:' + memoryId + ' -->' + nl, 'utf8')
41
43
  }
42
44
 
45
+ /**
46
+ * issue #54 P1(可诊断性):把 `parseAnchors` 的 conflicts 格式化为**带行号**的 reason。
47
+ *
48
+ * 旧实现统一 `.map(c => c.type)` ⇒ 丢掉 `line`/`byteStart`/`byteEnd`
49
+ * (`memory-anchor.js:184` 其实已经算好了),报错只说"冲突",
50
+ * 使用者(和模型)无从定位是哪一行,也不知道是自己这次写入引入的还是文件本来就有。
51
+ *
52
+ * 保持 `conflict:` 前缀不变(既有测试断言 `reason.startsWith('conflict')`)。
53
+ * @param {Array} conflicts parseAnchors 返回的冲突对象数组
54
+ * @returns {string} 形如 `conflict:orphan-content@12`(多个以 `,` 连接)
55
+ */
56
+ function formatConflicts(conflicts) {
57
+ return 'conflict:' + (conflicts || [])
58
+ .map((c) => c.type + (Number.isFinite(c.line) ? '@' + c.line : ''))
59
+ .join(',')
60
+ }
61
+
62
+ /** issue #54 P0:写入路径统一的保留语法前置校验(复用 memory-anchor-pre 的同一判据)。 */
63
+ function reservedSyntaxGuard(text) {
64
+ return checkReservedSyntaxInContent(text)
65
+ }
66
+
43
67
  /** 逆序插入 marker 到指定行首位置(批次内部按 atByte 升序传入)。 */
44
68
  function insertMarkers(buf, inserts, nl) {
45
69
  let out = buf
@@ -65,7 +89,7 @@ export function applyMigrationPlan(content, plan) {
65
89
  if (plan.expectedFileDigest !== sha256Hex(buf)) return { ok: false, reason: 'stale-plan' }
66
90
  const parsed = parseAnchors(buf)
67
91
  if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
68
- if (parsed.status !== 'clean') return { ok: false, reason: 'conflict:' + parsed.conflicts.map((c) => c.type).join(',') }
92
+ if (parsed.status !== 'clean') return { ok: false, reason: formatConflicts(parsed.conflicts), conflicts: parsed.conflicts }
69
93
  const pending = plan.operations.filter((op) => op && op.kind === 'insert-anchor')
70
94
  if (!pending.length) return { ok: true, applied: 0, text: buf }
71
95
  const legacyStarts = new Set(parsed.records.filter((r) => r.kind === 'legacy').map((r) => r.byteStart))
@@ -95,11 +119,14 @@ export function applyMigrationPlan(content, plan) {
95
119
  export function appendAnchoredRecord(content, { memoryId, text }) {
96
120
  if (typeof memoryId !== 'string' || !MEMORY_ID_RE.test(memoryId)) return { ok: false, reason: 'bad-id' }
97
121
  if (typeof text !== 'string' || !text.trim()) return { ok: false, reason: 'empty-record' }
122
+ // issue #54 P0:先校验**本次要写的正文**,再校验文件已有内容——顺序不可颠倒。
123
+ const guard = reservedSyntaxGuard(text)
124
+ if (!guard.ok) return { ok: false, reason: guard.reason, line: guard.line, detail: guard.detail }
98
125
  const buf = toBuf(content)
99
126
  if (buf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
100
127
  const parsed = parseAnchors(buf)
101
128
  if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
102
- if (parsed.status !== 'clean') return { ok: false, reason: 'conflict:' + parsed.conflicts.map((c) => c.type).join(',') }
129
+ if (parsed.status !== 'clean') return { ok: false, reason: formatConflicts(parsed.conflicts), conflicts: parsed.conflicts }
103
130
  if (parsed.records.some((r) => r.kind === 'anchored' && r.memoryId === memoryId)) return { ok: false, reason: 'duplicate-id' }
104
131
  const nl = parsed.newline === 'crlf' ? '\r\n' : '\n'
105
132
  const body = toEol(text, parsed.newline)
@@ -127,10 +154,10 @@ export function renderReplace(content, replacement, opts = {}) {
127
154
  if (rep.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized-replacement' }
128
155
  const oldParsed = parseAnchors(oldBuf)
129
156
  if (oldParsed.status === 'oversized') return { ok: false, reason: 'oversized' }
130
- if (oldParsed.status !== 'clean') return { ok: false, reason: 'conflict:' + oldParsed.conflicts.map((c) => c.type).join(',') }
157
+ if (oldParsed.status !== 'clean') return { ok: false, reason: formatConflicts(oldParsed.conflicts), conflicts: oldParsed.conflicts }
131
158
  const rp = parseAnchors(rep)
132
159
  if (rp.status === 'oversized') return { ok: false, reason: 'oversized-replacement' }
133
- if (rp.status !== 'clean') return { ok: false, reason: 'conflict:' + rp.conflicts.map((c) => c.type).join(','), conflicts: rp.conflicts }
160
+ if (rp.status !== 'clean') return { ok: false, reason: formatConflicts(rp.conflicts), conflicts: rp.conflicts }
134
161
  const oldIds = new Set(oldParsed.records.filter((r) => r.kind === 'anchored' && r.memoryId).map((r) => r.memoryId))
135
162
  const idFactory = (typeof opts.idFactory === 'function' ? opts.idFactory : newMemoryId)
136
163
  const used = new Set(oldIds)
@@ -169,11 +196,14 @@ export function renderReplace(content, replacement, opts = {}) {
169
196
  export function replaceSingleRecord(content, text, opts = {}) {
170
197
  const body = typeof text === 'string' ? text : String(text == null ? '' : text)
171
198
  if (!body.trim()) return { ok: false, reason: 'empty-record' }
199
+ // issue #54 P0:单记录写入的正文**整段都是内容**(marker 由本函数生成),故正文内出现保留语法必属误用 ⇒ 前置拒绝。
200
+ const guard = reservedSyntaxGuard(body)
201
+ if (!guard.ok) return { ok: false, reason: guard.reason, line: guard.line, detail: guard.detail }
172
202
  const oldBuf = toBuf(content)
173
203
  if (oldBuf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
174
204
  const oldParsed = parseAnchors(oldBuf)
175
205
  if (oldParsed.status === 'oversized') return { ok: false, reason: 'oversized' }
176
- if (oldParsed.status !== 'clean') return { ok: false, reason: 'conflict:' + oldParsed.conflicts.map((c) => c.type).join(',') }
206
+ if (oldParsed.status !== 'clean') return { ok: false, reason: formatConflicts(oldParsed.conflicts), conflicts: oldParsed.conflicts }
177
207
  const idFactory = typeof opts.idFactory === 'function' ? opts.idFactory : newMemoryId
178
208
  const used = new Set(oldParsed.records.filter((r) => r.kind === 'anchored').map((r) => r.memoryId))
179
209
  let memoryId
@@ -186,7 +216,7 @@ export function replaceSingleRecord(content, text, opts = {}) {
186
216
  const nl = detectNewline(oldBuf.length ? oldBuf : Buffer.from(body, 'utf8')) === 'crlf' ? '\r\n' : '\n'
187
217
  const candidate = Buffer.concat([markerBuf(memoryId, nl), Buffer.from(toEol(body, nl), 'utf8')])
188
218
  const check = parseAnchors(candidate)
189
- if (check.status !== 'clean') return { ok: false, reason: 'conflict:' + check.conflicts.map((c) => c.type).join(','), conflicts: check.conflicts }
219
+ if (check.status !== 'clean') return { ok: false, reason: formatConflicts(check.conflicts), conflicts: check.conflicts }
190
220
  const anchored = check.records.filter((r) => r.kind === 'anchored')
191
221
  if (anchored.length !== 1 || anchored[0].memoryId !== memoryId) return { ok: false, reason: 'not-single-record' }
192
222
  return { ok: true, text: candidate, memoryId }
@@ -194,24 +224,70 @@ export function replaceSingleRecord(content, text, opts = {}) {
194
224
 
195
225
  /**
196
226
  * 原子替换默认 fs 适配器之外的注入目标(测试故障注入/sidecar 目录等)。
197
- * 同目录临时文件 + fsync + rename;任何失败清理临时文件并抛出。
227
+ * 同目录临时文件 + fsync + **有界 rename 重试**(issue #48);替换失败保留完整候选快照,不覆盖回放。
228
+ *
229
+ * 2026-09-16 修正(issue #48):旧实现 rename 一次失败即硬失败并 unlink 临时文件,
230
+ * 在 Windows 并发子代理下(DSH 仍持有目标句柄)会把本可成功的写入连残骸一起丢掉。
231
+ * 现在:① 瞬时错误码(EPERM/EACCES/EBUSY)走退避重试;② 仍失败则把**完整候选快照**
232
+ * 改名保留为 `.dam-failed-*.tmp` 并在错误上回传 `recoveryPath`,由调用方人工比对
233
+ * (候选是**整篇快照**而非追加指令,绝不允许自动回放——期间可能有别的写入者推进了目标)。
198
234
  */
199
- export async function atomicReplace(target, data, fsApi = fsDefault) {
235
+ export async function atomicReplace(target, data, fsApi = fsDefault, opts = {}) {
200
236
  const dir = path.dirname(target)
201
- const tmp = path.join(dir, '.dam-pre-tmp-' + randomUUID().slice(0, 8) + '-' + path.basename(target))
237
+ const nonce = randomUUID()
238
+ // .tmp 后缀保证 待处理/恢复 快照不进入 *.md / *.json 扫描(见 issue #51 同类问题)
239
+ const tmp = path.join(dir, '.dam-pre-tmp-' + nonce + '-' + path.basename(target) + '.tmp')
202
240
  await fsApi.mkdir(dir, { recursive: true })
203
241
  let handle = null
242
+ let created = false
243
+ let complete = false
244
+ let stage = 'open'
204
245
  try {
205
- handle = await fsApi.open(tmp, 'w')
246
+ // 独占创建:碰撞时绝不截断/删除别人的临时文件
247
+ handle = await fsApi.open(tmp, 'wx', 0o600)
248
+ created = true
249
+ stage = 'write'
206
250
  await handle.writeFile(data)
251
+ stage = 'sync'
207
252
  await handle.sync()
253
+ stage = 'close'
208
254
  await handle.close()
209
255
  handle = null
210
- await fsApi.rename(tmp, target)
211
- } catch (e) {
256
+ complete = true
257
+ stage = 'rename'
258
+ await retryRename(tmp, target, { fs: fsApi, delays: opts.renameDelays, sleep: opts.sleep })
259
+ } catch (cause) {
212
260
  if (handle) { try { await handle.close() } catch (_) {} }
213
- try { await fsApi.unlink(tmp) } catch (_) {}
214
- throw e
261
+ const details = { stage, targetPath: target, recoveryComplete: false }
262
+ if (created && complete && opts.preserveOnFailure !== false) {
263
+ // 这是**整篇文档的候选快照**,不是追加指令 —— 绝不自动回放(别的写入者可能已推进目标)。
264
+ let recoveryPath = tmp
265
+ const failed = path.join(dir, '.dam-failed-' + Date.now() + '-' + nonce + '-' + path.basename(target) + '.tmp')
266
+ try { await fsApi.rename(tmp, failed); recoveryPath = failed } catch (_) {
267
+ // 恢复用的 rename 本身也可能被占用 ⇒ 保留原临时路径
268
+ }
269
+ try {
270
+ const retained = await fsApi.stat(recoveryPath)
271
+ if (!retained.isFile()) throw new Error('recovery snapshot is not a file')
272
+ details.recoveryPath = recoveryPath
273
+ details.recoveryComplete = true
274
+ } catch (_) {
275
+ // 快照可能已被扫描器/其它进程移走 —— 不能仅凭"本函数没 unlink"就宣称保全成功
276
+ details.recoveryUnavailable = true
277
+ }
278
+ } else if (created) {
279
+ try { await fsApi.unlink(tmp) } catch (cleanupError) {
280
+ details.partialPath = tmp
281
+ details.cleanupCode = cleanupError && cleanupError.code
282
+ }
283
+ }
284
+ // 默认保留原始 fs 错误(含 code/errno/syscall/path/dest)。
285
+ // 冻结/非 Error 抛出不得用 TypeError 掩盖真实写入失败。
286
+ const error = cause instanceof Error && Object.isExtensible(cause)
287
+ ? cause : new Error(cause && cause.message ? cause.message : String(cause), { cause })
288
+ if (error !== cause && cause && typeof cause.code === 'string') error.code = cause.code
289
+ Object.assign(error, details)
290
+ throw error
215
291
  }
216
292
  }
217
293
 
@@ -222,6 +298,39 @@ export async function atomicReplace(target, data, fsApi = fsDefault) {
222
298
  * expectedDigest 不匹配(外部编辑) → 拒绝且不写。同文件并发写经队列串行,不丢失。
223
299
  * fs/sidecarDir/backupDir 可注入(故障注入测试);sidecarDir 未配置则不做 sidecar 落盘。
224
300
  */
301
+ // 同一 fs 后端共享队列(注入的虚拟文件系统之间仍互相隔离)。**注意:这不是跨进程锁。**
302
+ const queuesByFs = new WeakMap()
303
+ function queuesFor(fsApi) {
304
+ let queues = queuesByFs.get(fsApi)
305
+ if (!queues) { queues = new Map(); queuesByFs.set(fsApi, queues) }
306
+ return queues
307
+ }
308
+
309
+ /** 队列键:Windows 下大小写不敏感且需规范化,避免同一文件两条队列并行。 */
310
+ export function memoryWriteLockKey(filePath, platform = process.platform) {
311
+ const resolved = (platform === 'win32' ? path.win32 : path.posix).resolve(filePath)
312
+ return platform === 'win32' ? resolved.toLowerCase() : resolved
313
+ }
314
+
315
+ /** 把 store 的结构化写状态保留成 Error(issue #48):写入失败不可降级为一句无信息的文案。 */
316
+ export function memoryWriteError(operation, result) {
317
+ let message = 'memory-anchor-' + operation + '-failed:' + result.reason
318
+ if (result.recoveryPath) {
319
+ message += '; recoveryPath=' + JSON.stringify(result.recoveryPath) +
320
+ '; recovery is a candidate snapshot, compare with current document before manual recovery'
321
+ }
322
+ if (result.partialPath) message += '; partialPath=' + JSON.stringify(result.partialPath) + '; incomplete, not safe to restore'
323
+ if (result.written === true) message += '; written=true, verify current document before retrying'
324
+ const error = new Error(message)
325
+ error.code = result.errorCode || (result.written === true ? 'MEMORY_WRITE_VERIFY_FAILED' : 'MEMORY_WRITE_FAILED')
326
+ error.fsCode = result.fsCode
327
+ error.written = result.written === true
328
+ error.recoveryPath = result.recoveryPath
329
+ error.recoveryComplete = result.recoveryComplete === true
330
+ error.partialPath = result.partialPath
331
+ return error
332
+ }
333
+
225
334
  export class MemoryDocumentStore {
226
335
  constructor(opts = {}) {
227
336
  this.fs = opts.fs || fsDefault
@@ -229,11 +338,12 @@ export class MemoryDocumentStore {
229
338
  this.backupDir = opts.backupDir || null
230
339
  this.now = opts.now || (() => Date.now())
231
340
  this.idFactory = opts.idFactory || newMemoryId
232
- this._locks = new Map()
341
+ this._locks = queuesFor(this.fs)
342
+ this.atomicOptions = opts.atomicOptions || {}
233
343
  }
234
344
 
235
345
  _queue(filePath, job) {
236
- const key = path.resolve(filePath)
346
+ const key = memoryWriteLockKey(filePath)
237
347
  const prev = this._locks.get(key) || Promise.resolve()
238
348
  const run = prev.then(job, job)
239
349
  const settled = run.then(() => {}, () => {})
@@ -247,13 +357,68 @@ export class MemoryDocumentStore {
247
357
  try {
248
358
  const buf = await this.fs.readFile(filePath)
249
359
  const parsed = parseAnchors(buf)
250
- return { buf, parsed, fileDigest: sha256Hex(buf) }
360
+ // P1 步 3:状态版本(`expectedStateVersion`)取自 sidecar 的 `sourceVersion` + `epoch`。
361
+ // 仅当 sidecarDir 配置且 sidecar 可读时才有值;否则为 null ⇒ 状态闸按 "(unknown)" 拒绝
362
+ // (fail-closed:证明不了"同一版本"就不写,与身份门的既有口径一致)。
363
+ let stateVersion = null
364
+ if (this.sidecarDir) {
365
+ const cur = await this.readSidecar(filePath)
366
+ if (cur && cur.ok && cur.sidecar) {
367
+ const sv = cur.sidecar.sourceVersion
368
+ const ep = cur.sidecar.epoch
369
+ if (sv != null) stateVersion = (ep != null ? String(ep) + ':' : '') + String(sv)
370
+ }
371
+ }
372
+ return { buf, parsed, fileDigest: sha256Hex(buf), stateVersion }
251
373
  } catch (e) {
252
- if (e && e.code === 'ENOENT') return { buf: null, parsed: null, fileDigest: null }
374
+ if (e && e.code === 'ENOENT') return { buf: null, parsed: null, fileDigest: null, stateVersion: null }
253
375
  throw e
254
376
  }
255
377
  }
256
378
 
379
+ /**
380
+ * ★2026-09-15(P1 步 3 · 设计稿 §2.3):**提交边界内**的版本校验(并发原子边界)。
381
+ *
382
+ * **为什么必须在这里、而不是调用方**:P1 卡明确 —— "两个写者都先读 D、都通过比较、再分别
383
+ * 写 A 和 B ⇒ 后写者仍会覆盖前写者"。唯一正确的做法是让"读当前状态 → 比较 → 写"三步
384
+ * **在同一个队列任务内**完成(`_queue` 按路径串行,见 `:235`)。本助手只被 `_queue(...)`
385
+ * **内部**调用,因此天然满足该边界;绝不要在队列外用它做预检。
386
+ *
387
+ * 双闸(各自独立,都可单独启用):
388
+ * - `expectedDigest`:字节级(防"用户改了文件")—— 既有语义,保持不动。
389
+ * - `expectedStateVersion`:状态级(防"另一个窗口改了图/换了状态")—— P1 新增。
390
+ *
391
+ * **兼容档(T1-8)**:两者都可缺省;缺省即不校验,行为与 P1 之前**逐字节一致**。
392
+ * **可见冲突(T1-7C)**:拒绝时带 `expected/observed/target`,由调用方决定是否渲染成文本。
393
+ *
394
+ * ⚠️ 副作用零:只读 `state` 入参,不写盘、不改 state。
395
+ */
396
+ _checkCommitBoundary(filePath, state, opts) {
397
+ const target = String(filePath || '')
398
+ const wantsDigest = opts.expectedDigest != null
399
+ const wantsStateVersion = opts.expectedStateVersion != null
400
+ // ① 字节闸(既有语义):不匹配 ⇒ 拒绝且不写
401
+ if (wantsDigest && state.fileDigest !== opts.expectedDigest) {
402
+ return {
403
+ ok: false,
404
+ reason: 'conflict-external-edit',
405
+ conflict: { kind: 'digest', target, expected: String(opts.expectedDigest), observed: state.fileDigest == null ? '(missing)' : String(state.fileDigest) },
406
+ }
407
+ }
408
+ // ② 状态闸(P1 新增):sidecar 的 sourceVersion 为状态版本;无 sidecar/无 prev 时视为 unknown
409
+ if (wantsStateVersion) {
410
+ const cur = state.stateVersion == null ? null : String(state.stateVersion)
411
+ if (cur !== String(opts.expectedStateVersion)) {
412
+ return {
413
+ ok: false,
414
+ reason: 'conflict-state-version',
415
+ conflict: { kind: 'state-version', target, expected: String(opts.expectedStateVersion), observed: cur == null ? '(unknown)' : cur },
416
+ }
417
+ }
418
+ }
419
+ return { ok: true }
420
+ }
421
+
257
422
  /** sidecar 路径:sidecarDir + '<sha256(canonicalSourcePath)>.json'(契约 §6;canonical=resolve+正斜杠+小写)。 */
258
423
  sidecarPath(filePath) {
259
424
  if (!this.sidecarDir) return null
@@ -266,7 +431,9 @@ export class MemoryDocumentStore {
266
431
  const sp = this.sidecarPath(filePath)
267
432
  if (!sp) throw new Error('no-sidecar-dir')
268
433
  await this.fs.mkdir(path.dirname(sp), { recursive: true })
269
- await atomicReplace(sp, Buffer.from(JSON.stringify(sidecar, null, 2) + '\n', 'utf8'), this.fs)
434
+ // sidecar 是**可重建的派生数据**:失败时保留候选快照只会积累垃圾,故显式关闭 preserveOnFailure
435
+ // (与 Markdown 正文档相反——正文档失败必须保住快照供人工比对)。
436
+ await atomicReplace(sp, Buffer.from(JSON.stringify(sidecar, null, 2) + '\n', 'utf8'), this.fs, { ...this.atomicOptions, preserveOnFailure: false })
270
437
  }
271
438
 
272
439
  /** 读已落盘 sidecar;损坏返回 {ok:false,reason} 由调用方隔离并从 Markdown 重建。 */
@@ -297,9 +464,16 @@ export class MemoryDocumentStore {
297
464
  }
298
465
  }
299
466
  try {
300
- await atomicReplace(filePath, out, this.fs)
467
+ await atomicReplace(filePath, out, this.fs, this.atomicOptions)
301
468
  } catch (e) {
302
- return { ok: false, reason: 'write-failed:' + (e && e.message ? e.message : String(e)) }
469
+ // issue #48:结构化保留失败态(含 recoveryPath/partialPath),供上层给出可操作报错
470
+ return {
471
+ ok: false, reason: 'write-failed:' + (e && e.message ? e.message : String(e)),
472
+ errorCode: 'MEMORY_WRITE_FAILED', fsCode: e && e.code, written: false,
473
+ recoveryPath: e && e.recoveryPath, recoveryComplete: !!(e && e.recoveryComplete),
474
+ partialPath: e && e.partialPath, stage: e && e.stage,
475
+ recoveryUnavailable: !!(e && e.recoveryUnavailable),
476
+ }
303
477
  }
304
478
  let reread
305
479
  try { reread = await this.fs.readFile(filePath) } catch (e) { return { ok: false, reason: 'verify-read-failed', written: true } }
@@ -331,7 +505,8 @@ export class MemoryDocumentStore {
331
505
  append(filePath, text, opts = {}) {
332
506
  return this._queue(filePath, async () => {
333
507
  const state = await this._readState(filePath)
334
- if (opts.expectedDigest != null && state.fileDigest !== opts.expectedDigest) return { ok: false, reason: 'conflict-external-edit' }
508
+ const gate = this._checkCommitBoundary(filePath, state, opts)
509
+ if (!gate.ok) return gate
335
510
  const memoryId = opts.memoryId || this.idFactory()
336
511
  const app = appendAnchoredRecord(state.buf, { memoryId, text })
337
512
  if (!app.ok) return app
@@ -344,7 +519,8 @@ export class MemoryDocumentStore {
344
519
  replace(filePath, replacement, opts = {}) {
345
520
  return this._queue(filePath, async () => {
346
521
  const state = await this._readState(filePath)
347
- if (opts.expectedDigest != null && state.fileDigest !== opts.expectedDigest) return { ok: false, reason: 'conflict-external-edit' }
522
+ const gate = this._checkCommitBoundary(filePath, state, opts)
523
+ if (!gate.ok) return gate
348
524
  const rr = renderReplace(state.buf, replacement, { idFactory: opts.idFactory || this.idFactory })
349
525
  if (!rr.ok) return rr
350
526
  const res = await this._commit(filePath, rr.text, { prevSidecar: opts.prevSidecar })
@@ -356,7 +532,8 @@ export class MemoryDocumentStore {
356
532
  replaceSingle(filePath, text, opts = {}) {
357
533
  return this._queue(filePath, async () => {
358
534
  const state = await this._readState(filePath)
359
- if (opts.expectedDigest != null && state.fileDigest !== opts.expectedDigest) return { ok: false, reason: 'conflict-external-edit' }
535
+ const gate = this._checkCommitBoundary(filePath, state, opts)
536
+ if (!gate.ok) return gate
360
537
  const rr = replaceSingleRecord(state.buf, text, { idFactory: opts.idFactory || this.idFactory })
361
538
  if (!rr.ok) return rr
362
539
  const res = await this._commit(filePath, rr.text, { prevSidecar: opts.prevSidecar })
@@ -368,6 +545,9 @@ export class MemoryDocumentStore {
368
545
  applyPlan(filePath, plan, opts = {}) {
369
546
  return this._queue(filePath, async () => {
370
547
  const state = await this._readState(filePath)
548
+ // P1 步 3:本方法同样写盘 ⇒ 必须走同一边界校验(此前它连 expectedDigest 都未检查)
549
+ const gate = this._checkCommitBoundary(filePath, state, opts)
550
+ if (!gate.ok) return gate
371
551
  const ap = applyMigrationPlan(state.buf, plan)
372
552
  if (!ap.ok) return ap
373
553
  const res = await this._commit(filePath, ap.text, { prevSidecar: opts.prevSidecar })