@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +189 -7
  2. package/README.zh-CN.md +189 -7
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/FRONTEND-CO-CREATION.md +191 -0
  5. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  6. package/docs/HANDOFF-CRITERIA.md +92 -0
  7. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  8. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  9. package/docs/PROMO-PROMPT-3.0.md +100 -0
  10. package/docs/USER-GUIDE.en.md +58 -3
  11. package/docs/USER-GUIDE.zh-CN.md +59 -4
  12. package/docs/WHITEPAPER.md +207 -0
  13. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  14. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  15. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  16. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  17. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  18. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  19. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  20. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  21. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  22. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  23. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  24. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  25. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  26. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  27. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  28. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  29. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  30. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  31. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  32. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  33. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  34. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  35. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  36. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  37. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  38. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  39. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  40. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  41. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  42. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  43. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  44. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  45. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  46. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  47. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  48. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  49. package/docs/internal/KICKOFF-P0.md +254 -0
  50. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  51. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  52. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  53. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  54. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  55. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  56. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  57. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  58. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  59. package/docs/internal/PROGRESS-20260917.md +93 -0
  60. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  61. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  62. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  63. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  64. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  65. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  66. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  67. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  68. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  69. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  70. package/docs/internal/RESUME-20260918.md +171 -0
  71. package/docs/internal/RESUME-20260919.md +104 -0
  72. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  73. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  74. package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
  75. package/docs/internal/ROADMAP.md +106 -0
  76. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  77. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
  78. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  79. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  80. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  81. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  82. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  83. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  84. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  85. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  86. package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
  87. package/docs/internal/TODO-BACKLOG.md +263 -142
  88. package/docs/internal/TODO-GRAPH.html +715 -0
  89. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  90. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  91. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  92. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  93. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  94. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  95. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  96. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  97. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  98. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  99. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  100. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  101. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  102. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  103. package/docs/prompts/M8-3-enable-verify.md +49 -49
  104. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  105. package/lib/acceptance.js +71 -0
  106. package/lib/activation-host.js +153 -18
  107. package/lib/activation-inbox.js +25 -7
  108. package/lib/board-mode.js +30 -0
  109. package/lib/client.js +1758 -90
  110. package/lib/config-io.js +156 -0
  111. package/lib/context-bridge.js +5 -2
  112. package/lib/context-host.js +86 -15
  113. package/lib/degrade.js +385 -0
  114. package/lib/dsh-home.js +143 -0
  115. package/lib/engine-identity.js +149 -0
  116. package/lib/engine-switch.js +247 -0
  117. package/lib/episodic-store.js +63 -12
  118. package/lib/evidence-store.js +10 -3
  119. package/lib/fact-store.js +22 -3
  120. package/lib/fs-retry.js +46 -0
  121. package/lib/index-sync.js +13 -1
  122. package/lib/index.js +3446 -263
  123. package/lib/intent-clean-safe.js +258 -0
  124. package/lib/intent-clean.js +12 -16
  125. package/lib/l0-extract.js +478 -149
  126. package/lib/l0-index-sync.js +195 -0
  127. package/lib/l0-index.js +349 -239
  128. package/lib/ledger-criteria.js +142 -0
  129. package/lib/m4-corpus.js +8 -2
  130. package/lib/m7-index-sync-host.js +73 -5
  131. package/lib/m7-wire.js +3 -3
  132. package/lib/memory-anchor.js +56 -1
  133. package/lib/memory-envelope.js +257 -0
  134. package/lib/memory-hub.js +138 -13
  135. package/lib/memory-index.js +4 -2
  136. package/lib/memory-mutation.js +246 -0
  137. package/lib/memory-writer.js +204 -24
  138. package/lib/note-status-apply.js +118 -0
  139. package/lib/note-status.js +196 -0
  140. package/lib/procedure-observation.js +48 -0
  141. package/lib/procedure-store.js +118 -20
  142. package/lib/python-setup.js +1 -1
  143. package/lib/python-sidecar-client.js +29 -3
  144. package/lib/recall-fusion.js +83 -12
  145. package/lib/rerank-host.js +160 -0
  146. package/lib/rules-edit.js +159 -0
  147. package/lib/rules-layer.js +261 -0
  148. package/lib/semantic-decide.js +41 -8
  149. package/lib/semantic-js.js +66 -6
  150. package/lib/shadow-host.js +3 -5
  151. package/lib/shadow-retrieval.js +3 -3
  152. package/lib/skill-export-host.js +153 -0
  153. package/lib/skill-export.js +239 -0
  154. package/lib/state-commit.js +245 -0
  155. package/lib/storage-manage.js +6 -0
  156. package/lib/subagent-gc.js +4 -8
  157. package/lib/temporal-parse.js +191 -159
  158. package/lib/tier-layer-inject.js +650 -0
  159. package/lib/tier0-catalog.js +735 -0
  160. package/lib/water-window.js +263 -186
  161. package/lib/wb-contract.js +691 -0
  162. package/lib/wb-sidecar.js +890 -0
  163. package/lib/ws-overview-rank.js +2 -2
  164. package/package.json +1 -1
  165. package/python/m7_embedding_v1.py +5 -5
  166. package/python/worker_semantic_v1.py +17 -6
  167. package/python/worker_v1.py +38 -4
@@ -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 })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 结论层状态 · **条目级应用**(G3 写盘的核心纯函数)
3
+ *
4
+ * 职责:把一条 `status` 落到**指定 memoryId 的条目正文末尾**,返回**新文本**(不写盘)。
5
+ * 与 `note-status.js` 的分工:
6
+ * - `note-status.js` = 状态行的**语法**(渲染/解析/剥离)
7
+ * - 本模块 = 状态行的**定位与落点**(在文件里找到那条、放到末尾)
8
+ *
9
+ * ── 与既有写入通道的关系(重要)───────────────────────────────
10
+ * 本模块**不代替** `memory-writer` 事务写入,只产出**新全文**;
11
+ * 落盘仍由调用方走既有通道(备份/校验/无 BOM 等纪律不绕过)。
12
+ *
13
+ * ── 为什么必须「只动目标条目」─────────────────────────────────
14
+ * MEMORY.md 是**用户可见的明文**且 25+ 锚点共存。任何"顺手重排/格式化"都会:
15
+ * ① 让无关条目的 `recordDigest` 变化 ⇒ sidecar sourceVersion 无谓 +1 ⇒ 全量缓存失效;
16
+ * ② 制造巨大的 diff,用户在 GUI 里看不出"到底改了什么"。
17
+ * ⇒ 契约:**除目标条目的状态行外,逐字节保持原样**(含 CRLF 行尾)。
18
+ *
19
+ * 纪律:纯函数、零 IO、fail-soft(不改动即返回 null,绝不返回半成品文本)。
20
+ */
21
+ import { NOTE_STATUS_OPEN_V1, renderStatusLinePre, stripStatusLinePre, statusOfBodyPre } from './note-status.js'
22
+
23
+ /** 锚点:与 `l0-extract.js` / `memory-anchor.js` 同形态(此处独立声明,避免耦合)。 */
24
+ const ANCHOR_RE = /<!--\s*memory:(mem_[0-9a-f]{32})\s*-->/g
25
+
26
+ /**
27
+ * 定位一条条目在原文中的**正文区间**。
28
+ *
29
+ * 语义与 `parseMemoryItemsPre` 一致:anchor marker **其后**的内容归该条,
30
+ * 直到**下一个** marker 之前(或文件末尾)。
31
+ *
32
+ * @param {string} text 文件全文
33
+ * @param {string} memoryId
34
+ * @returns {{start:number, end:number, id:string}|null} 正文的 [start,end) 字符区间
35
+ */
36
+ export function locateRecordBodyPre(text, memoryId) {
37
+ try {
38
+ const src = String(text == null ? '' : text)
39
+ if (!src || !/^mem_[0-9a-f]{32}$/.test(String(memoryId || ''))) return null
40
+ ANCHOR_RE.lastIndex = 0
41
+ const marks = []
42
+ let m
43
+ while ((m = ANCHOR_RE.exec(src)) !== null) {
44
+ // 同时记录 marker 的**真实**起止(不重建字符串 —— 锚点允许空白浮动)
45
+ marks.push({ id: m[1], start: m.index, end: m.index + m[0].length })
46
+ if (m.index === ANCHOR_RE.lastIndex) ANCHOR_RE.lastIndex++
47
+ }
48
+ for (let i = 0; i < marks.length; i++) {
49
+ if (marks[i].id !== memoryId) continue
50
+ const start = marks[i].end
51
+ // 正文止于**下一个 marker 的起始**(该 marker 及其后内容不属于本条)
52
+ const end = i + 1 < marks.length ? marks[i + 1].start : src.length
53
+ return { start, end, id: memoryId }
54
+ }
55
+ return null
56
+ } catch (_) { return null }
57
+ }
58
+
59
+ /**
60
+ * ★ 主函数:给指定条目应用状态,返回**新全文**。
61
+ *
62
+ * 行为契约:
63
+ * - 目标不存在 ⇒ `null`(**fail-soft**:绝不凭空创建条目)
64
+ * - `status === 'current'` ⇒ **剥掉**既有状态行(撤销通道;正文其余不变)
65
+ * - 已是目标状态且 reason/by 未变 ⇒ 返回**原文**(幂等,调用方可据此跳过写盘)
66
+ * - 其余 ⇒ 剥旧状态行 + 在**正文末尾**追加新状态行
67
+ * - **行尾风格沿用原文件**(CRLF 保持 CRLF;本仓文件全 CRLF)
68
+ * - 任何异常 ⇒ `null`(绝不返回半成品)
69
+ *
70
+ * @param {string} text 文件全文
71
+ * @param {string} memoryId 目标条目
72
+ * @param {string} status current | superseded | retracted
73
+ * @param {{supersededBy?:string, reason?:string}} [opts]
74
+ * @returns {string|null} 新全文;不可应用时为 null
75
+ */
76
+ export function applyStatusToRecordPre(text, memoryId, status, opts = {}) {
77
+ try {
78
+ const src = String(text == null ? '' : text)
79
+ if (!src) return null
80
+ const loc = locateRecordBodyPre(src, memoryId)
81
+ if (!loc) return null
82
+
83
+ const seg = src.slice(loc.start, loc.end)
84
+ // CRLF 感知:本仓文件全 CRLF,必须沿用,否则整文件 diff 爆炸
85
+ const eol = seg.includes('\r\n') ? '\r\n' : '\n'
86
+
87
+ // 先把段落按当前 EOL 归一化切分,处理后再拼回
88
+ const bodyClean = stripStatusLinePre(seg)
89
+ const trimmed = bodyClean.replace(/[\r\n\s]+$/, '')
90
+
91
+ const line = renderStatusLinePre(status, opts)
92
+ let next = line ? trimmed + eol + line + eol : (trimmed ? trimmed + eol : '')
93
+ // 段落与下一个 marker 之间保留一个空行(与既有文件形态一致)
94
+ next = next ? next + eol : next
95
+
96
+ const out = src.slice(0, loc.start) + next + src.slice(loc.end)
97
+ // 幂等:无变化 ⇒ 返回原文(调用方可据此跳过写盘,避免无谓 sourceVersion +1)
98
+ return out === src ? src : out
99
+ } catch (_) { return null }
100
+ }
101
+
102
+ /**
103
+ * 只读查询:某条目当前状态(供接线侧判断是否需要写)。
104
+ * 与 `statusOfBodyPre` 同源,此处补上「文件级」定位。
105
+ *
106
+ * @returns {{status:string, supersededBy?:string, reason?:string}|null} 条目不存在 ⇒ null
107
+ */
108
+ export function readRecordStatusPre(text, memoryId) {
109
+ try {
110
+ const src = String(text == null ? '' : text)
111
+ const loc = locateRecordBodyPre(src, memoryId)
112
+ if (!loc) return null
113
+ return statusOfBodyPre(src.slice(loc.start, loc.end))
114
+ } catch (_) { return null }
115
+ }
116
+
117
+ /** 供反向锁使用:确认本模块**不碰锚点语法**。 */
118
+ export const NOTE_STATUS_MARKER_V1 = NOTE_STATUS_OPEN_V1