@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.
- package/README.md +171 -1
- package/README.zh-CN.md +171 -1
- package/docs/CONTRIBUTORS.html +471 -0
- package/docs/HANDOFF-CRITERIA.md +92 -0
- package/docs/INTEGRATION-ANALYSIS.md +350 -348
- package/docs/USER-GUIDE.en.md +56 -1
- package/docs/USER-GUIDE.zh-CN.md +57 -2
- package/docs/internal/ACCEPT-35-LIVE.md +143 -0
- package/docs/internal/ACCEPTANCE-20260914.md +90 -0
- package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
- package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
- package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
- package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
- package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
- package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
- package/docs/internal/KICKOFF-P0.md +254 -0
- package/docs/internal/MASTER-PLAN-3.0.md +411 -0
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
- package/docs/internal/PENDING-FIXES-20260916.md +289 -0
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
- package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
- package/docs/internal/ROADMAP-20260917-WEEK.md +305 -0
- package/docs/internal/ROADMAP.md +106 -0
- package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +175 -0
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
- package/docs/internal/THREE-LAYER-CONTRACT.md +210 -0
- package/docs/internal/TODO-BACKLOG.md +263 -142
- package/docs/internal/TODO-GRAPH.html +715 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
- package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
- package/docs/prompts/M8-3-enable-verify.md +49 -49
- package/lib/acceptance.js +71 -0
- package/lib/activation-host.js +90 -9
- package/lib/activation-inbox.js +25 -7
- package/lib/board-mode.js +30 -0
- package/lib/client.js +880 -68
- package/lib/context-bridge.js +2 -2
- package/lib/context-host.js +70 -6
- package/lib/engine-identity.js +149 -0
- package/lib/engine-switch.js +247 -0
- package/lib/episodic-store.js +11 -10
- package/lib/evidence-store.js +2 -2
- package/lib/fact-store.js +1 -1
- package/lib/fs-retry.js +46 -0
- package/lib/index.js +1987 -153
- package/lib/intent-clean-safe.js +40 -0
- package/lib/intent-clean.js +12 -16
- package/lib/l0-extract.js +263 -149
- package/lib/l0-index-sync.js +195 -0
- package/lib/l0-index.js +349 -239
- package/lib/ledger-criteria.js +142 -0
- package/lib/m7-index-sync-host.js +65 -4
- package/lib/m7-wire.js +3 -3
- package/lib/memory-anchor.js +56 -1
- package/lib/memory-envelope.js +252 -0
- package/lib/memory-hub.js +14 -4
- package/lib/memory-mutation.js +246 -0
- package/lib/memory-writer.js +204 -24
- package/lib/procedure-observation.js +48 -0
- package/lib/procedure-store.js +34 -17
- package/lib/python-setup.js +1 -1
- package/lib/rerank-host.js +160 -0
- package/lib/rules-layer.js +261 -0
- package/lib/semantic-js.js +15 -0
- package/lib/shadow-retrieval.js +3 -3
- package/lib/state-commit.js +245 -0
- package/lib/subagent-gc.js +4 -8
- package/lib/tier-layer-inject.js +650 -0
- package/lib/tier0-catalog.js +693 -0
- package/lib/water-window.js +88 -2
- package/lib/wb-contract.js +495 -0
- package/lib/wb-sidecar.js +839 -0
- package/lib/ws-overview-rank.js +2 -2
- package/package.json +1 -1
- package/python/m7_embedding_v1.py +5 -5
- package/python/worker_semantic_v1.py +17 -6
- 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
|
+
}
|
package/lib/memory-writer.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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 =
|
|
341
|
+
this._locks = queuesFor(this.fs)
|
|
342
|
+
this.atomicOptions = opts.atomicOptions || {}
|
|
233
343
|
}
|
|
234
344
|
|
|
235
345
|
_queue(filePath, job) {
|
|
236
|
-
const key =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 })
|