@a9i5k4/dsh-auto-memory 3.1.5 → 3.1.6

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 (92) hide show
  1. package/docs/CONTINUITY-FLOW.md +6 -6
  2. package/docs/HANDBOOK.md +54 -53
  3. package/docs/M-CM-PLAN.md +1 -1
  4. package/docs/M-CM-STATE.md +1 -1
  5. package/docs/M-CM7-HANDOFF-LAYERED-RETRIEVAL.md +1 -1
  6. package/docs/M3B-CONTRACT.md +7 -7
  7. package/docs/M7-AUTONOMOUS-STATE.md +1 -1
  8. package/docs/M7-TASKSET-DISPATCH.md +1 -1
  9. package/docs/PROMPT-PACK-LAYERED-RECALL.md +3 -3
  10. package/docs/PROMPT-SET-STRICT.md +3 -3
  11. package/docs/RELEASE-GO-NOGO.md +1 -1
  12. package/docs/STATUS-BOARD.md +1 -1
  13. package/docs/USER-GUIDE.en.md +21 -21
  14. package/docs/USER-GUIDE.zh-CN.md +21 -21
  15. package/docs/WHITEPAPER.md +1 -1
  16. package/docs/prompts/FIX-AGENT-P12-FULL-REGRESSION.md +1 -1
  17. package/docs/prompts/LIVE-VERIFY-ZCODE.md +2 -2
  18. package/docs/prompts/P2-semantic-recall.md +2 -2
  19. package/docs/prompts/P7-write-fix.md +1 -1
  20. package/docs/prompts/ZCODE-DROPIN.md +1 -1
  21. package/lib/acceptance.js +6 -6
  22. package/lib/activation-host.js +15 -14
  23. package/lib/activation-inbox-state.js +4 -4
  24. package/lib/activation-inbox.js +44 -44
  25. package/lib/board-mode.js +3 -3
  26. package/lib/client.js +193 -34
  27. package/lib/config-io.js +12 -12
  28. package/lib/context-bridge.js +22 -22
  29. package/lib/context-host.js +24 -23
  30. package/lib/datadir.js +95 -0
  31. package/lib/degrade.js +21 -21
  32. package/lib/dsh-home.js +8 -8
  33. package/lib/engine-identity.js +12 -12
  34. package/lib/engine-switch.js +3 -3
  35. package/lib/episodic-store.js +13 -13
  36. package/lib/evidence-agg.js +4 -4
  37. package/lib/evidence-store.js +12 -12
  38. package/lib/fact-store.js +47 -47
  39. package/lib/handoff-anchor.js +4 -4
  40. package/lib/hub-io.js +356 -3
  41. package/lib/index-sync.js +5 -5
  42. package/lib/index.js +381 -236
  43. package/lib/intent-clean-safe.js +3 -3
  44. package/lib/intent-clean.js +1 -1
  45. package/lib/l0-extract.js +15 -15
  46. package/lib/l0-index-sync.js +8 -8
  47. package/lib/l0-index.js +11 -11
  48. package/lib/ledger-criteria.js +8 -8
  49. package/lib/m4-corpus.js +1 -1
  50. package/lib/m7-index-sync-host.js +2 -2
  51. package/lib/m7-wire.js +24 -24
  52. package/lib/memory-anchor.js +1 -1
  53. package/lib/memory-envelope.js +15 -15
  54. package/lib/memory-hub.js +95 -50
  55. package/lib/memory-importance.js +7 -7
  56. package/lib/memory-mutation.js +5 -5
  57. package/lib/note-status-apply.js +2 -2
  58. package/lib/note-status.js +7 -7
  59. package/lib/policies/activation_policy_v2.json +2 -2
  60. package/lib/policies/recall_intent_lr_v1.json +1 -1
  61. package/lib/procedure-observation.js +2 -2
  62. package/lib/procedure-store.js +117 -28
  63. package/lib/python-setup.js +6 -5
  64. package/lib/python-sidecar-client.js +26 -26
  65. package/lib/recall-fusion.js +12 -12
  66. package/lib/recall-stats.js +36 -1
  67. package/lib/rerank-host.js +11 -11
  68. package/lib/rules-edit.js +9 -9
  69. package/lib/rules-layer.js +26 -26
  70. package/lib/semantic-decide.js +7 -7
  71. package/lib/semantic-js.js +13 -13
  72. package/lib/shadow-host.js +7 -6
  73. package/lib/shadow-retrieval.js +40 -40
  74. package/lib/skill-export-host.js +7 -7
  75. package/lib/skill-export.js +6 -6
  76. package/lib/state-commit.js +10 -10
  77. package/lib/storage-manage.js +6 -6
  78. package/lib/tier-layer-inject.js +28 -28
  79. package/lib/tier0-catalog.js +4 -4
  80. package/lib/wb-contract.js +45 -45
  81. package/lib/wb-sidecar.js +11 -11
  82. package/package.json +3 -4
  83. package/python/m7_activation_features_v2.py +7 -7
  84. package/python/m7_embedding_v1.py +12 -12
  85. package/python/policies/activation_policy_v2.json +2 -2
  86. package/python/policies/decision-record-activation-v2-delta-exp-override-20260824.json +1 -1
  87. package/python/policies/decision-record-reasoning-kind-admission-20260826.json +2 -2
  88. package/python/policies/decision-record-stale-gate-per-candidate-20260825.json +2 -2
  89. package/python/policies/recall_intent_lr_v1.json +1 -1
  90. package/python/verify_policy_artifact.py +3 -3
  91. package/python/worker_semantic_v1.py +18 -18
  92. package/python/worker_v1.py +18 -18
package/lib/hub-io.js CHANGED
@@ -23,11 +23,11 @@
23
23
  * · 不做 unlink/copy 回退:调用方各自保留自己的原子性策略(同 `fs-retry.js` 的边界声明)。
24
24
  */
25
25
 
26
- import { mkdirSync, writeFileSync, renameSync, readFileSync, rmSync } from 'node:fs'
26
+ import { mkdirSync, writeFileSync, renameSync, readFileSync, rmSync, existsSync } from 'node:fs'
27
27
  import path from 'node:path'
28
28
 
29
29
  /** errno → 人话。口径:暴露给前端/工具的原因必须人能看懂,不能只甩机器码。 */
30
- export const HUB_IO_ERRNO_MESSAGES_V1 = Object.freeze({
30
+ export const HUB_IO_ERRNO_MESSAGES_PRE_V1 = Object.freeze({
31
31
  EACCES: '权限被拒绝(目标目录不可写)',
32
32
  EPERM: '操作被系统拒绝(权限或安全策略拦截,Windows 下也常见于文件被占用)',
33
33
  ENOSPC: '磁盘空间不足',
@@ -45,7 +45,7 @@ export const HUB_IO_ERRNO_MESSAGES_V1 = Object.freeze({
45
45
  /** 把任意异常翻成一句人话(带 errno 便于排障;无 code 时回落 message)。 */
46
46
  export function explainHubIoErrorPre(e) {
47
47
  const code = e && e.code ? String(e.code) : ''
48
- const mapped = code ? HUB_IO_ERRNO_MESSAGES_V1[code] : ''
48
+ const mapped = code ? HUB_IO_ERRNO_MESSAGES_PRE_V1[code] : ''
49
49
  if (mapped) return mapped + '(' + code + ')'
50
50
  if (code) return '文件系统错误 ' + code
51
51
  return String((e && e.message) || e || '未知错误')
@@ -215,3 +215,356 @@ export function hubIoHealthSnapshotPre(health) {
215
215
  return empty
216
216
  }
217
217
  }
218
+
219
+ /**
220
+ * ★M8-B(2026-09-23 用户拍板,方案甲):**procedure 双库 IO —— 合并读 + 按 scope 分派写**。
221
+ *
222
+ * ── 为什么单独做一层 ──
223
+ * `createHubIoPre` 是**三店共用**的通用适配器(episodes / facts / procedures)。给它加作用域语义
224
+ * 会顺带改变另两店行为。故作用域只在本层实现,**只给 procedures 用**;另两店仍走原路径,
225
+ * 行为逐字节不变。
226
+ *
227
+ * ── 落点(与 migrate-pack 既有分层对齐)──
228
+ * · 通用库(跨工作区) = `<globalDir>/<name>`(既有 55 条所在,原地不动)
229
+ * · 工作区库(本工作区) = `<resolveWorkspace().dir>/<name>`(新增)
230
+ *
231
+ * ── 三条硬约束 ──
232
+ * ① **工作区目录必须惰性解析**:hub 在**引擎构造期**就建好(index.js 的 hubDir 同为构造期常量),
233
+ * 而工作区 state.ws 要等 _doRefresh 才赋值 ⇒ 构造期缓存会锁死空工作区,
234
+ * 随后 save 就可能把**空集**写进工作区库(灾难性覆盖)。故每次调用都现算。
235
+ * ② **路径未知一律不写工作区库**:宁可这一轮不落盘(内存态仍完整、下轮再写),
236
+ * 也绝不用空集/错集覆盖用户的工作区技能库。所有跳过都**可见**(返回值 + health 记账)。
237
+ * ③ **读坏过的文件不覆盖**:load 时「文件存在却解析失败」记入 corrupt 集,save 对这类文件**拒写**。
238
+ * 既有实现在「快照损坏 → load 返回 null → 内存空 → 下次 save 整份覆盖」下会**静默清空全部条目**;
239
+ * 双库把这一暴露面翻倍,故在此收紧为「宁可不写」。
240
+ *
241
+ * ── 明确声明的取舍 ──
242
+ * procedures 的 save **不参与** createHubIoPre 的批内合并(批深度由 hub 侧的通用工厂持有,
243
+ * 本层看不到)⇒ 批内每次 persist 都是一次整份原子写。这是**刻意选择**:本轮刚因「机器定时回写」
244
+ * 付出过代价,宁可多写几次也不引入新的延迟写盘通道;数据量有界(当前 55 条 / 约 100KB)。
245
+ * 若日后实测写放大成问题,再单独评估合并策略 —— **不要顺手加定时器**。
246
+ *
247
+ * @param {object} opts
248
+ * @param {string} opts.globalDir 通用库目录(必填)
249
+ * @param {string} [opts.name='procedures.json'] 文件名
250
+ * @param {function} opts.resolveWorkspace () => {dir,key};未知时给空串(必填)
251
+ * @param {function} [opts.isWorkspaceScoped] (entry) => boolean;缺省 scope==='workspace'
252
+ * @param {object} [opts.health] 复用 createHubIoHealthPre() 台账
253
+ * @param {function} [opts.onError] (key,msg) 失败回调
254
+ * @param {object} [opts.fsApi] 测试注入(透传给 createHubIoPre)
255
+ * @returns {{save:Function, load:Function, clear:Function, lastWrite:Function, isCorrupt:Function}}
256
+ */
257
+ export function createScopedHubIoPre(opts = {}) {
258
+ const globalDir = String(opts.globalDir || '')
259
+ const fileName = String(opts.name || 'procedures.json')
260
+ const health = opts.health || createHubIoHealthPre()
261
+ const onError = opts.onError
262
+ const resolveWorkspace = typeof opts.resolveWorkspace === 'function'
263
+ ? opts.resolveWorkspace
264
+ : () => ({ dir: '', key: '' })
265
+ const isWs = typeof opts.isWorkspaceScoped === 'function'
266
+ ? opts.isWorkspaceScoped
267
+ : (e) => !!(e && e.scope === 'workspace')
268
+
269
+ // 每目录一个既有工厂(复用其原子写与 health 记账;不新建第二套 IO 实现)
270
+ const factories = new Map()
271
+ function ioFor(dir) {
272
+ let f = factories.get(dir)
273
+ if (!f) { f = createHubIoPre({ dir, health, onError, fsApi: opts.fsApi }); factories.set(dir, f) }
274
+ return f
275
+ }
276
+ const corrupt = new Set()
277
+ let last = {
278
+ at: 0, wroteGlobal: false, wroteWorkspace: false,
279
+ skippedUnknownWs: 0, skippedForeign: 0, refusedCorrupt: 0, foreignIds: [],
280
+ }
281
+
282
+ /** 取当前工作区 {dir,key};任何异常都退化为「未知」⇒ 走约束②不写。 */
283
+ function wsNow() {
284
+ try {
285
+ const r = resolveWorkspace() || {}
286
+ return { dir: String(r.dir || ''), key: String(r.key || '') }
287
+ } catch (e) {
288
+ notePre(health, fileName, 'resolve-ws', e, onError)
289
+ return { dir: '', key: '' }
290
+ }
291
+ }
292
+
293
+ /** 读一份;「文件存在但解析失败 ⇒ null」记入 corrupt(供 save 拒写)。 */
294
+ function readOne(dir) {
295
+ const file = path.join(dir, fileName)
296
+ const data = ioFor(dir)(fileName).load()
297
+ let existed
298
+ try { existed = existsSync(file) } catch (_) { existed = false }
299
+ if (data === null && existed) corrupt.add(dir)
300
+ else corrupt.delete(dir)
301
+ return data
302
+ }
303
+
304
+ function load() {
305
+ const g = readOne(globalDir)
306
+ const w = wsNow()
307
+ const wsData = w.dir ? readOne(w.dir) : null
308
+ const gp = (g && Array.isArray(g.procedures)) ? g.procedures : []
309
+ const wp = (wsData && Array.isArray(wsData.procedures)) ? wsData.procedures : []
310
+ const any = g || wsData
311
+ if (!any) return null // 两库皆无 ⇒ 保持既有「空启动」语义
312
+ if (!gp.length && !wp.length) return Object.assign({}, any, { procedures: [] })
313
+ // 同 id 去重:**工作区优先**(更具体的一侧胜出)
314
+ const byId = new Map()
315
+ for (const p of gp) byId.set(String(p && p.procedureId), p)
316
+ for (const p of wp) byId.set(String(p && p.procedureId), p)
317
+ return {
318
+ schemaVersion: (g && g.schemaVersion) || (wsData && wsData.schemaVersion) || 1,
319
+ namespace: (g && g.namespace) || (wsData && wsData.namespace) || undefined,
320
+ policyVersion: (g && g.policyVersion) || (wsData && wsData.policyVersion) || undefined,
321
+ savedAt: Date.now(),
322
+ procedures: [...byId.values()],
323
+ }
324
+ }
325
+
326
+ function save(snapshot) {
327
+ const all = (snapshot && Array.isArray(snapshot.procedures)) ? snapshot.procedures : []
328
+ const w = wsNow()
329
+ const globals = []
330
+ const mine = []
331
+ const foreign = []
332
+ for (const p of all) {
333
+ if (!isWs(p)) { globals.push(p); continue }
334
+ const ref = String((p && p.workspaceRef) || '')
335
+ // 归属本工作区;ref 缺失时按「当前库」保守处理(不丢用户刚写的条目)
336
+ if (!ref || !w.key || ref === w.key) mine.push(p)
337
+ else foreign.push(p)
338
+ }
339
+ // 约束③:读坏过的文件拒写(宁可不动,也不静默清空)
340
+ let refused = 0
341
+ let wroteGlobal = false
342
+ if (corrupt.has(globalDir)) {
343
+ refused += globals.length
344
+ } else {
345
+ try {
346
+ ioFor(globalDir)(fileName).save(Object.assign({}, snapshot, { procedures: globals }))
347
+ wroteGlobal = true
348
+ } catch (_) { wroteGlobal = false }
349
+ }
350
+ let wroteWorkspace = false
351
+ if (!w.dir) {
352
+ // 约束②:路径未知 ⇒ 不写工作区库(不抛错,但必须可见)
353
+ if (mine.length) {
354
+ notePre(health, fileName, 'ws-unknown', new Error('工作区目录未解析:工作区库未写入,' + mine.length + ' 条留在内存,下轮再写'), onError)
355
+ }
356
+ } else if (corrupt.has(w.dir)) {
357
+ refused += mine.length
358
+ } else {
359
+ try {
360
+ ioFor(w.dir)(fileName).save(Object.assign({}, snapshot, { procedures: mine }))
361
+ wroteWorkspace = true
362
+ } catch (_) { wroteWorkspace = false }
363
+ }
364
+ last = {
365
+ at: Date.now(), wroteGlobal, wroteWorkspace,
366
+ skippedUnknownWs: w.dir ? 0 : mine.length,
367
+ skippedForeign: foreign.length,
368
+ refusedCorrupt: refused,
369
+ foreignIds: foreign.map((p) => String(p && p.procedureId)).slice(0, 5),
370
+ }
371
+ return Object.assign({ ok: wroteGlobal || wroteWorkspace }, last)
372
+ }
373
+
374
+ function clear() {
375
+ let ok = true
376
+ try { ioFor(globalDir)(fileName).clear(); corrupt.delete(globalDir) } catch (e) {
377
+ ok = false; notePre(health, fileName, 'clear-global', e, onError)
378
+ }
379
+ const w = wsNow()
380
+ if (w.dir) {
381
+ try { ioFor(w.dir)(fileName).clear(); corrupt.delete(w.dir) } catch (e) {
382
+ ok = false; notePre(health, fileName, 'clear-ws', e, onError)
383
+ }
384
+ }
385
+ return { ok }
386
+ }
387
+
388
+
389
+ /**
390
+ * ★M8-B 第4步:**两阶段迁移**(写接收库 → 读回校验 → 写捐赠库 → 读回校验;任一失败整体回滚)。
391
+ *
392
+ * 为什么必须两阶段:跨文件移动**不是原子的**。写目标成功、删源失败 ⇒ 条目复制成两份;
393
+ * 反序则更糟——删源成功、写目标失败 ⇒ **条目直接消失**。故这里定死两条:
394
+ * ① **先写接收库、后写捐赠库**——失败时最坏是「多留一份」而非「丢一份」,
395
+ * 即 **fail toward duplication, never toward loss**(可见、可重试、可人工清理)。
396
+ * ② 每写一步就**读回校验**,不是写完就当成功(写盘函数的返回不等于内容真落对)。
397
+ * 任一步校验不过:把两库恢复到**迁移前快照**,并如实返回 rolledBack。
398
+ *
399
+ * @param {Array<{procedureId:string, scope:string, workspaceRef?:string}>} entries 目标归属
400
+ * @param {function} [onProgress] (done,total,item) —— 进度是两阶段提交的**可见化**
401
+ * @returns {{ok:boolean,total:number,moved:number,failed:number,rolledBack:boolean,results:Array}}
402
+ */
403
+ function migrate(entries, onProgress) {
404
+ const list = (Array.isArray(entries) ? entries : [entries]).filter(Boolean)
405
+ const total = list.length
406
+ const results = []
407
+ let moved = 0
408
+ let failed = 0
409
+ let rolledBack = false
410
+ const w = wsNow()
411
+
412
+ // 回滚凭据:两库**迁移前**的解析快照(null = 该库无文件/为空;损坏库已被 readOne 记入 corrupt)
413
+ const globalBefore = readOne(globalDir)
414
+ const wsBefore = w.dir ? readOne(w.dir) : null
415
+ // 深拷一份作为回滚源,避免后续 map 改动污染凭据
416
+ const snapOf = (x) => (x && Array.isArray(x.procedures))
417
+ ? Object.assign({}, x, { procedures: x.procedures.map((p) => Object.assign({}, p)) })
418
+ : null
419
+
420
+ function rollbackBoth() {
421
+ let ok = true
422
+ try { ioFor(globalDir)(fileName).save(snapOf(globalBefore) || { schemaVersion: 1, procedures: [] }) }
423
+ catch (e) { ok = false; notePre(health, fileName, 'migrate-rollback-global', e, onError) }
424
+ if (w.dir) {
425
+ try { ioFor(w.dir)(fileName).save(snapOf(wsBefore) || { schemaVersion: 1, procedures: [] }) }
426
+ catch (e) { ok = false; notePre(health, fileName, 'migrate-rollback-ws', e, onError) }
427
+ }
428
+ return ok
429
+ }
430
+
431
+ // 以「迁移前快照」为工作副本,逐条改归属;两条目的条目集合由 scope 决定
432
+ let gList = (globalBefore && Array.isArray(globalBefore.procedures)) ? globalBefore.procedures.map((p) => Object.assign({}, p)) : []
433
+ let wList = (wsBefore && Array.isArray(wsBefore.procedures)) ? wsBefore.procedures.map((p) => Object.assign({}, p)) : []
434
+ const findIn = (arr, id) => arr.findIndex((p) => String(p && p.procedureId) === String(id))
435
+
436
+ for (let i = 0; i < list.length; i++) {
437
+ const it = list[i] || {}
438
+ const id = String(it.procedureId || '')
439
+ const targetScope = it.scope === 'workspace' ? 'workspace' : 'global'
440
+ const targetRef = String(it.workspaceRef || '')
441
+ const tag = { procedureId: id, targetScope, targetRef }
442
+ // 目标库落地检查:要进工作区库但工作区未知 ⇒ 明确失败(绝不猜一个目录写下去)
443
+ if (targetScope === 'workspace' && (!w.dir || !w.key)) {
444
+ failed++
445
+ results.push(Object.assign({ ok: false, reason: 'ws-unknown' }, tag))
446
+ if (onProgress) onProgress(i + 1, total, results[results.length - 1])
447
+ continue
448
+ }
449
+ if (targetScope === 'workspace' && targetRef && targetRef !== w.key) {
450
+ failed++
451
+ results.push(Object.assign({ ok: false, reason: 'foreign-workspace' }, tag))
452
+ if (onProgress) onProgress(i + 1, total, results[results.length - 1])
453
+ continue
454
+ }
455
+ // 定位源(两库都找)
456
+ let gi = findIn(gList, id)
457
+ let wi = findIn(wList, id)
458
+ // ★双库同 id:先按 `load()` 的**既有读优先级(工作区优先)**去重,维持「一个 id 只住在一个库」。
459
+ // 不做这步的实测后果:接收库那条留着、又从捐赠库推一条进来 ⇒ 同一 id 两份
460
+ // —— 正是两阶段迁移专门要防的「复制成两份」,静态检查与语法检查都发现不了。
461
+ if (gi >= 0 && wi >= 0) { gList.splice(gi, 1); gi = findIn(gList, id) }
462
+ const src = gi >= 0 ? 'global' : (wi >= 0 ? 'workspace' : null)
463
+ if (!src) {
464
+ failed++
465
+ results.push(Object.assign({ ok: false, reason: 'not-found' }, tag))
466
+ if (onProgress) onProgress(i + 1, total, results[results.length - 1])
467
+ continue
468
+ }
469
+ if (src === targetScope) {
470
+ // 已在目标库:只把 workspaceRef 校正到位(幂等,不动位置)
471
+ const arr = src === 'global' ? gList : wList
472
+ const idx = src === 'global' ? gi : wi
473
+ arr[idx].scope = targetScope
474
+ if (targetScope === 'workspace') arr[idx].workspaceRef = w.key
475
+ moved++
476
+ results.push(Object.assign({ ok: true, noop: true, from: src, to: targetScope }, tag))
477
+ if (onProgress) onProgress(i + 1, total, results[results.length - 1])
478
+ continue
479
+ }
480
+ // 迁移:先改内存副本
481
+ const entry = src === 'global' ? gList.splice(gi, 1)[0] : wList.splice(wi, 1)[0]
482
+ entry.scope = targetScope
483
+ entry.workspaceRef = targetScope === 'workspace' ? w.key : ''
484
+ const receiving = targetScope === 'workspace' ? wList : gList
485
+ const donating = targetScope === 'workspace' ? gList : wList
486
+ receiving.push(entry)
487
+
488
+ // 两阶段提交
489
+ const recvDir = targetScope === 'workspace' ? w.dir : globalDir
490
+ const donDir = targetScope === 'workspace' ? globalDir : w.dir
491
+ const recvSnap = { schemaVersion: 1, namespace: 'dsh-auto-memory-pre', procedures: receiving }
492
+ const donSnap = { schemaVersion: 1, namespace: 'dsh-auto-memory-pre', procedures: donating }
493
+ let step = 'write-receiving'
494
+ let stepOk = false
495
+ try {
496
+ if (corrupt.has(recvDir)) throw new Error('receiving library corrupt: ' + recvDir)
497
+ ioFor(recvDir)(fileName).save(recvSnap)
498
+ step = 'verify-receiving'
499
+ const back = ioFor(recvDir)(fileName).load()
500
+ const hit = back && Array.isArray(back.procedures) && back.procedures.some((p) => String(p.procedureId) === id && (p.scope || 'global') === targetScope)
501
+ if (!hit) throw new Error('receiving verify failed')
502
+ step = 'write-donating'
503
+ if (corrupt.has(donDir)) throw new Error('donating library corrupt: ' + donDir)
504
+ ioFor(donDir)(fileName).save(donSnap)
505
+ step = 'verify-donating'
506
+ const back2 = ioFor(donDir)(fileName).load()
507
+ const stillThere = back2 && Array.isArray(back2.procedures) && back2.procedures.some((p) => String(p.procedureId) === id)
508
+ if (stillThere) throw new Error('donating verify failed (still present)')
509
+ stepOk = true
510
+ } catch (e) {
511
+ notePre(health, fileName, 'migrate-' + step, e, onError)
512
+ // 回滚:两库都恢复到迁移前快照;同时把内存副本复位,避免下一条基于坏状态继续
513
+ rolledBack = true
514
+ rollbackBoth()
515
+ gList = (globalBefore && Array.isArray(globalBefore.procedures)) ? globalBefore.procedures.map((p) => Object.assign({}, p)) : []
516
+ wList = (wsBefore && Array.isArray(wsBefore.procedures)) ? wsBefore.procedures.map((p) => Object.assign({}, p)) : []
517
+ failed++
518
+ results.push(Object.assign({ ok: false, reason: 'phase-failed', step, error: String((e && e.message) || e) }, tag))
519
+ if (onProgress) onProgress(i + 1, total, results[results.length - 1])
520
+ continue
521
+ }
522
+ if (stepOk) {
523
+ moved++
524
+ results.push(Object.assign({ ok: true, from: src, to: targetScope, phase: 'committed' }, tag))
525
+ }
526
+ if (onProgress) onProgress(i + 1, total, results[results.length - 1])
527
+ }
528
+
529
+ last = {
530
+ at: Date.now(), wroteGlobal: false, wroteWorkspace: false,
531
+ skippedUnknownWs: 0, skippedForeign: 0, refusedCorrupt: 0, foreignIds: [],
532
+ migrated: moved, migrateFailed: failed, rolledBack,
533
+ }
534
+ return { ok: failed === 0, total, moved, failed, rolledBack, results }
535
+ }
536
+ /**
537
+ * ★M8-B 第6步(2026-09-23):**只读**描述两库现状,供前端「双库视图」回答
538
+ * 「当前是哪个库 / 各库各有多少条 / 能不能写工作区库」。
539
+ *
540
+ * 为什么不把路径给前端:前端不需要、也不该拿到真实磁盘路径(那是注入面与
541
+ * 迁移包才关心的事);这里只回**库标识与计数**,足够渲染归属徽标与禁用理由。
542
+ * 读盘失败一律 fail-soft 记 -1(前端显示「不可读」),绝不让面板整个挂掉。
543
+ */
544
+ function describe() {
545
+ const w = wsNow()
546
+ const countIn = (dir) => {
547
+ if (!dir) return -1
548
+ try {
549
+ const d = readOne(dir)
550
+ return (d && Array.isArray(d.procedures)) ? d.procedures.length : 0
551
+ } catch (_) { return -1 }
552
+ }
553
+ return {
554
+ defaultScope: 'global',
555
+ workspaceKey: w.key,
556
+ workspaceReady: !!(w.dir && w.key),
557
+ globalDirCorrupt: corrupt.has(String(globalDir || '')),
558
+ workspaceDirCorrupt: w.dir ? corrupt.has(String(w.dir)) : false,
559
+ globalCount: countIn(globalDir),
560
+ // 工作区未知时不探盘:`countIn('')` 会回 -1,含义是「当前无法归属」而非「0 条」
561
+ workspaceCount: countIn(w.dir),
562
+ }
563
+ }
564
+
565
+ return {
566
+ save, load, clear, migrate, describe,
567
+ lastWrite: () => Object.assign({}, last),
568
+ isCorrupt: (dir) => corrupt.has(String(dir || '')),
569
+ }
570
+ }
package/lib/index-sync.js CHANGED
@@ -13,7 +13,7 @@ import { workspaceRefOf } from './evidence-store.js'
13
13
  import {
14
14
  buildChunkIdPre, buildSyncIdPre, computePageDigestPre, computeFinalDigestPre,
15
15
  validateIndexSyncBeginPre, validateIndexSyncPagePre, validateIndexSyncCommitPre, validateSemanticRecordPre,
16
- M7_INDEX_POLICY_VERSION_V1,
16
+ M7_INDEX_POLICY_VERSION_PRE_V1,
17
17
  } from './m7-wire.js'
18
18
 
19
19
  /**
@@ -28,7 +28,7 @@ import {
28
28
  *
29
29
  * 故留 **4KiB 余量**(≈ 实测信封 202–230B 的 18 倍)。
30
30
  */
31
- export const INDEX_SYNC_PAGE_BUDGET_V1 = Object.freeze({ maxRecordsPerPage: 64, maxPageBytes: 252 * 1024 })
31
+ export const INDEX_SYNC_PAGE_BUDGET_PRE_V1 = Object.freeze({ maxRecordsPerPage: 64, maxPageBytes: 252 * 1024 })
32
32
  const SCOPE_ORDER_PRE = ['Workspace', 'User']
33
33
 
34
34
  function pagePayloadBytes(syncId, pageNo, pageCount, records) {
@@ -67,12 +67,12 @@ export function toSemanticRecordPre(rec, workspaceRef) {
67
67
  * 返回 {ok:true, plans:[{scope, begin, pages[], commit}], recordTotal}|{ok:false, reason}。
68
68
  */
69
69
  export function buildIndexSyncPlansPre(input) {
70
- const B = INDEX_SYNC_PAGE_BUDGET_V1
70
+ const B = INDEX_SYNC_PAGE_BUDGET_PRE_V1
71
71
  const snap = input && input.snapshot
72
72
  const recordsRaw = Array.isArray(input && input.records) ? input.records : (snap ? snap.records : [])
73
73
  const sourcesAll = Array.isArray(input && input.sources) ? input.sources : (snap ? snap.sources : [])
74
74
  const miv = String((input && input.memoryIndexVersion) || (snap ? snap.memoryIndexVersion : '') || '')
75
- if (!miv.startsWith('idx_')) return { ok: false, reason: 'memoryIndexVersion' }
75
+ if (!miv.startsWith('idx_pre_')) return { ok: false, reason: 'memoryIndexVersion' }
76
76
  const wsRef = workspaceRefOf(String((input && input.workspaceKey) || ''))
77
77
  const byScope = new Map()
78
78
  for (const r of recordsRaw) {
@@ -118,7 +118,7 @@ export function buildIndexSyncPlansPre(input) {
118
118
  const pageCount = pagesRecords.length
119
119
  const begin = {
120
120
  schemaVersion: 1, syncId, workspaceRef: wsRef, scope, memoryIndexVersion: miv,
121
- sourceTuples, recordCount, pageCount, indexPolicyVersion: M7_INDEX_POLICY_VERSION_V1,
121
+ sourceTuples, recordCount, pageCount, indexPolicyVersion: M7_INDEX_POLICY_VERSION_PRE_V1,
122
122
  }
123
123
  const bv = validateIndexSyncBeginPre(begin)
124
124
  if (!bv.ok) return { ok: false, reason: 'begin:' + bv.reason }