@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
package/lib/memory-hub.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
2
+ import { isObservationOnlyPre } from './procedure-observation.js'
1
3
  /**
2
4
  * M8-3 Memory Hub 编排器(docs/PROJECT-FREEZE-AND-ROADMAP.md M8/M9; 记忆中枢)。
3
5
  * 纯内存编排,零 IO 依赖(node:crypto 仅身份);把三层记忆串成一条可即插即用的链:
@@ -49,6 +51,9 @@ export function createMemoryHubPre(opts = {}) {
49
51
  const { createEpisodicStorePre } = opts._stores || {}
50
52
  const { createFactStorePre } = opts._stores || {}
51
53
  const { createProcedureStorePre } = opts._stores || {}
54
+ // ★T10:机械 procedure 切片开关(默认关闭;缺省值在 index.js 的 DEFAULT_CONFIG)。
55
+ // 这里只读、不判定语义 —— 具体用途见 crossFeed() 的 procedure 分支。
56
+ const mechanicalProcedureFeedEnabled = opts.mechanicalProcedureFeedEnabled === true
52
57
  const nowFn = typeof opts.now === 'function' ? opts.now : () => Date.now()
53
58
  const log = typeof opts.log === 'function' ? opts.log : () => {}
54
59
 
@@ -94,10 +99,25 @@ export function createMemoryHubPre(opts = {}) {
94
99
  return { consumed: 'procedure', outcome: r.ok ? 'observed' : r.reason }
95
100
  }
96
101
  if (layer === 'episodic' && stores.episodic) {
97
- // episodic_candidate 已是巩固后的 episode,直接喂
98
- const r = stores.episodic.restore({ schemaVersion: 1, episodes: [row] })
99
- stats.consumedEpisodic++
100
- return { consumed: 'episodic', outcome: r.ok ? 'restored' : r.reason }
102
+ // ★ 2026-09-19 上游 PR #77 / issue #63 同步落地(**P0 数据丢失**):
103
+ // 旧实现走 `stores.episodic.restore({schemaVersion:1, episodes:[row]})`,而 `restore()` 是
104
+ // **快照整体替换**语义(先清空 episodes)。候选行普遍缺 validateEpisodePre 必填字段
105
+ // ⇒ 校验拒(restored:0),**但 episodes 已被清空、current 已置 null**,且仍返回 {ok:true}
106
+ // ⇒ 一次 ingest 抹掉全部已巩固 episode,不可逆。
107
+ // 现改走**增量导入**:不清空、逐条校验、按 episodeId 幂等。
108
+ // fallback:老 store 无 importEpisodes 时退化为"不导入",**绝不回退到 restore**。
109
+ if (typeof stores.episodic.importEpisodes !== 'function') {
110
+ stats.skipped++
111
+ return { skipped: true, reason: 'no-import-episodes' }
112
+ }
113
+ const r = stores.episodic.importEpisodes([row])
114
+ if (r.ok && r.imported > 0) {
115
+ stats.consumedEpisodic++
116
+ return { consumed: 'episodic', outcome: 'imported' }
117
+ }
118
+ // 如实区分:非法行 / 重复行都不算 consumed(旧实现把两种情况都记成 'restored')
119
+ stats.skipped++
120
+ return { skipped: true, reason: r.duplicates > 0 ? 'duplicate-episode' : 'rejected:' + r.rejected }
101
121
  }
102
122
  } catch (e) {
103
123
  log('memory-hub ingest error: ' + String(e && e.message || e))
@@ -128,11 +148,20 @@ export function createMemoryHubPre(opts = {}) {
128
148
  const out = []
129
149
  for (const ep of eps) {
130
150
  // 成功 episode → procedure 观察(固定流程雏形)
131
- if (ep.success && stores.procedures && ep.intent && ep.actions && ep.actions.length) {
151
+ // issue #30:episode 只提供"观察到一件事"的线索,**不足以**构成可晋升技能 ——
152
+ // 把 actions 直接当成 steps 会让观察行看起来像真流程,且与后续同名富候选撞车后被合并
153
+ // 而丢失富候选的 successCriteria(晋升永久卡死)。现在显式标 observationOnly=true,
154
+ // 并在 title 前过滤运行时信封(避免注入文本变成技能标题)。
155
+ const procedureIntent = stripRuntimeIntentPre(ep.intent).trim()
156
+ // ★T10(2026-09-20 用户报「技能名/内容看不懂」):**门控机械切片**。
157
+ // 关闭时整段跳过 ⇒ 不再产出 `intent.slice(0,40)` 这种机械观察行。
158
+ // ⚠️ 只包住 procedure 分支:下方 fact 分支与循环外的逻辑一律不受影响。
159
+ if (mechanicalProcedureFeedEnabled && ep.success && stores.procedures && procedureIntent && procedureIntent !== '(未提取)' && ep.actions && ep.actions.length) {
132
160
  const cand = {
133
- title: ep.intent.slice(0, 40),
161
+ title: procedureIntent.slice(0, 40),
134
162
  riskLevel: 'low',
135
- steps: ep.actions.map((a, i) => '步骤' + (i + 1) + ': ' + a),
163
+ steps: ['观察任务:' + procedureIntent.slice(0, 80)],
164
+ observationOnly: true,
136
165
  sourceEpisodes: [ep.episodeId],
137
166
  sourceMemoryIds: [],
138
167
  }
@@ -141,9 +170,24 @@ export function createMemoryHubPre(opts = {}) {
141
170
  }
142
171
  // 有未决事项的 episode → 事实候选(不直接固化,留给 judgement)
143
172
  if (ep.unresolved && ep.unresolved.length && stores.facts) {
173
+ // ★ T1-1(2026-09-19 真机追加):**fact 分支必须与 procedure 分支同样过清洗器**。
174
+ // 根因:上面 procedure 分支早已调 `stripRuntimeIntentPre`(:152),但本分支直接用
175
+ // **未清洗**的 `ep.intent` ⇒ 运行时信封/U+FFFD 原样进 facts.json ⇒ 前端面板乱码(⑩-a),
176
+ // 且经 `hubFlushTick` 写回 `MEMORY.md` 污染注入面与语义语料(⑩-b)。实测证据:
177
+ // `fact_ac4920327df6601f25200d66e52df71f` 的 subject 含 22 个 U+FFFD;
178
+ // 另两条的 object 内嵌 `Current DSH file policy: …` / `Approval prompts are disabled …`。
179
+ // 清洗后再截断,且**空值回退**到 'episode'(保持原 `|| 'episode'` 语义不变)。
180
+ // ★ F5(行内残留):清洗器是**按行**判断的,真人与信封挤在同一行时整行必须保留
181
+ // (删了会丢人话)⇒ 此时「清洗后是否变化」检测不到脏。故再补一道 `looksRuntimeResiduePre`:
182
+ // 该字段**含任何运行时痕迹即整体判脏并丢弃**(置空 ⇒ 走下面的空值回退),
183
+ // 而不是把半截信封写进 facts.json。
184
+ const rawIntent = String(ep.intent == null ? '' : ep.intent)
185
+ const rawObject = String(ep.unresolved[0] == null ? '' : ep.unresolved[0])
186
+ const factIntent = looksRuntimeResiduePre(rawIntent) ? '' : stripRuntimeIntentPre(rawIntent).trim()
187
+ const factObject = looksRuntimeResiduePre(rawObject) ? '' : stripRuntimeIntentPre(rawObject).trim()
144
188
  const cand = {
145
- scope: 'Workspace', subject: ep.intent.slice(0, 30) || 'episode', predicate: '有未决事项',
146
- object: ep.unresolved[0].slice(0, 60), sourceKind: 'inference',
189
+ scope: 'Workspace', subject: factIntent.slice(0, 30) || 'episode', predicate: '有未决事项',
190
+ object: factObject.slice(0, 60), sourceKind: 'inference',
147
191
  sourceClass: 'semantic-candidate', provenance: [ep.episodeId],
148
192
  }
149
193
  const r = stores.facts.upsert(cand)
@@ -188,7 +232,27 @@ export function createMemoryHubPre(opts = {}) {
188
232
  // 供 hubTab 审批按钮(晋升/激活/弃用)操作
189
233
  pipeline: stores.procedures.query()
190
234
  .filter((p) => p.stage !== 'active' && p.stage !== 'deprecated')
191
- .map((p) => ({ procedureId: p.procedureId, title: p.title, stage: p.stage, riskLevel: p.riskLevel, evidence: p.evidence, pinned: !!p.pinned })),
235
+ // issue #30:如实暴露 observationOnly —— 审批面必须能区分"可晋升技能"与"仅观察线索",
236
+ // 否则使用者会对着一个结构上不可能晋升的条目反复点晋升。
237
+ // ★R2(2026-09-20):补 `promotion` 判定投影 —— 用户要「晋升原因必须显式展示」。
238
+ // 走**纯只读**的 evaluatePromotion(),绝不在 overview 里碰 promote()(它写盘)。
239
+ // 失败时置 null(fail-soft:判定异常不得拖垮整个面板)。
240
+ .map((p) => {
241
+ let promotion = null
242
+ try {
243
+ promotion = stores.procedures.evaluatePromotion
244
+ ? stores.procedures.evaluatePromotion(p.procedureId)
245
+ : null
246
+ } catch (_) { promotion = null }
247
+ return {
248
+ procedureId: p.procedureId, title: p.title, stage: p.stage, riskLevel: p.riskLevel,
249
+ evidence: p.evidence, pinned: !!p.pinned, observationOnly: isObservationOnlyPre(p),
250
+ // R4 预览用:晋升后会注入的真实 checklist 文本
251
+ steps: Array.isArray(p.steps) ? p.steps.slice(0, 12) : [],
252
+ successCriteria: Array.isArray(p.successCriteria) ? p.successCriteria.slice(0, 6) : [],
253
+ promotion,
254
+ }
255
+ }),
192
256
  stats: stores.procedures.getStats ? stores.procedures.getStats() : null,
193
257
  } : null,
194
258
  }
@@ -226,11 +290,24 @@ export function factCandidateFromRow(row) {
226
290
  if (kind !== 'semantic_candidate' && kind !== 'profile_candidate') return null
227
291
  const sourceIds = Array.isArray(row.sourceIds) ? row.sourceIds : []
228
292
  if (!sourceIds.length) return null
293
+ // ★ T1-2(2026-09-19 真机追加):这是 fact 的**第二条入口**(judgement shadow 行),
294
+ // 与 `crossFeed` 的 fact 分支同源,同样必须过清洗器 —— 否则「补了 A 口、漏了 B 口」,
295
+ // 脏数据仍会经本函数进入 facts.json(再被 hubFlushTick 写回 MEMORY.md)。
296
+ // ★ F5:清洗器按行判断,**行内混信封**时整行保留 ⇒ 再加一道 `looksRuntimeResiduePre`,
297
+ // 命中即置空(走下面的空值回退),不把半截信封写进库。
298
+ const rawSubject = String(row.subject == null ? '' : row.subject)
299
+ const rawPredicate = String(row.predicate == null ? '' : row.predicate)
300
+ const rawObject = row.object === undefined || row.object === null ? null : String(row.object)
301
+ const cSubject = looksRuntimeResiduePre(rawSubject) ? '' : stripRuntimeIntentPre(rawSubject).trim()
302
+ const cPredicate = looksRuntimeResiduePre(rawPredicate) ? '' : stripRuntimeIntentPre(rawPredicate).trim()
303
+ const cObject = rawObject === null
304
+ ? null
305
+ : (looksRuntimeResiduePre(rawObject) ? null : (stripRuntimeIntentPre(rawObject).trim() || null))
229
306
  return {
230
307
  scope: row.scope === 'User' ? 'User' : 'Workspace',
231
- subject: String(row.subject || sourceIds[0]),
232
- predicate: String(row.predicate || 'relation'),
233
- object: row.object === undefined || row.object === null ? null : String(row.object),
308
+ subject: cSubject || String(sourceIds[0]),
309
+ predicate: cPredicate || 'relation',
310
+ object: cObject,
234
311
  sourceKind: 'inference',
235
312
  sourceClass: kind === 'profile_candidate' ? 'profile-candidate' : 'semantic-candidate',
236
313
  provenance: [...sourceIds],
@@ -257,3 +334,51 @@ export function procedureCandidateFromRow(row) {
257
334
  successCriteria: Array.isArray(row.successCriteria) ? row.successCriteria : [],
258
335
  }
259
336
  }
337
+
338
+ /**
339
+ * ④ 教训 → 观察型候选(2026-09-19 R5)。
340
+ *
341
+ * **用户裁定(2026-09-18 00:20)**:
342
+ * 「**教训肯定得进 C 啊,它不自动晋升,但是可以形成候选,模型也可以通过搜索搜索到**。
343
+ * 因为教训那边,我现在**自动注入的硬约束也是某种教训,把它上升到了约束层面**。」
344
+ *
345
+ * **通路设计**:
346
+ * `retracted` 条目 + 撤回原因(reason)→ 本函数 → `observationOnly: true` 观察型候选
347
+ * → `procedure-store.observe()` → **`promote()` 短路返回 `observation-only` ⇒ 永不自动晋升**
348
+ * → 但 `query()` 可检索到 ⇒ 模型能主动搜到「这条曾经被判错、原因是什么」。
349
+ *
350
+ * **三条硬约束(缺一即错)**:
351
+ * 1. **必须 `observationOnly: true`** —— 这是「永不自动晋升」的**唯一结构保证**。
352
+ * 若漏掉,它会变成可晋升富候选,与用户裁定直接冲突。
353
+ * 2. **`sourceMemoryIds` 必须带被撤回条目的 id** —— 教训的 provenance 是那条 retracted 记忆本身;
354
+ * 不得留空(留空会让 `addEvidence` 的 sourceMemoryIds 匹配计数恒零,且与
355
+ * episode→观察行 那条通路的语义混淆)。
356
+ * 注意:这与 `crossFeed()` 里 `sourceMemoryIds: []` 的**有意留空不同** ——
357
+ * 那里是"episode 只提供线索、不得凭空造 provenance";这里 id 是**真实存在**的。
358
+ * 3. **title/intent 必须先过信封清洗** —— 否则运行时信封会再次变成"教训标题"(H-3 同款)。
359
+ *
360
+ * @param {{memoryId?: string, title?: string, text?: string, reason?: string, retractedReason?: string}} row
361
+ * @returns {object|null} procedure candidate(observationOnly),输入不合法返回 null
362
+ */
363
+ export function lessonCandidateFromRetractedPre(row) {
364
+ if (!row || typeof row !== 'object') return null
365
+ const memoryId = String(row.memoryId || '').trim()
366
+ // 只认严格锚点 id 形态:与 G3 状态行同一套判据(防任意文本被当成 provenance 拼进去)
367
+ if (!/^mem_[0-9a-f]{32}$/.test(memoryId)) return null
368
+ const rawTitle = String(row.title || row.text || '').trim()
369
+ const title = stripRuntimeIntentPre(rawTitle).trim()
370
+ if (!title) return null
371
+ const reasonRaw = String(row.reason || row.retractedReason || '').trim()
372
+ const reason = stripRuntimeIntentPre(reasonRaw).trim().replace(/\s+/g, ' ').slice(0, 120)
373
+ return {
374
+ title: ('教训:' + title).slice(0, 60),
375
+ riskLevel: 'low',
376
+ // 步骤形态:明说「这是一条教训」+ 撤回原因(原因才是教训的正文)
377
+ steps: [reason ? ('曾判错,原因:' + reason + '。下次避免:' + title.slice(0, 60)) : ('曾判错:' + title.slice(0, 80))],
378
+ // ★ 约束 1:永不自动晋升的结构保证
379
+ observationOnly: true,
380
+ sourceEpisodes: [],
381
+ // ★ 约束 2:真实 provenance(非凭空构造)
382
+ sourceMemoryIds: [memoryId],
383
+ }
384
+ }
@@ -55,10 +55,12 @@ function decodedLine(buf, s, e) {
55
55
  function buildIndex(sourceFile, content, prev) {
56
56
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
57
57
  if (buf.length > INDEX_MAX_FILE_BYTES) {
58
- return { sourceFile, fileDigest: '', sourceVersion: (prev && prev.version) || 1, skipped: true, records: [] }
58
+ // ★2026-09-20 移植(issue #92 / PR #97):宿主缓存只写 sourceVersion,旧读 prev.version
59
+ // 在生产路径恒为 undefined ⇒ 版本封顶 2、未变重读回退 1。
60
+ return { sourceFile, fileDigest: '', sourceVersion: (prev && prev.sourceVersion) || 1, skipped: true, records: [] }
59
61
  }
60
62
  const fileDigest = createHash('sha256').update(buf).digest('hex')
61
- const sourceVersion = prev && prev.fileDigest === fileDigest ? (prev.version || 1) : (prev ? (prev.version || 1) + 1 : 1)
63
+ const sourceVersion = prev && prev.fileDigest === fileDigest ? (prev.sourceVersion || 1) : (prev ? (prev.sourceVersion || 1) + 1 : 1)
62
64
  const lines = splitByteLines(buf)
63
65
  const records = []
64
66
  let cur = null
@@ -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
+ }