dsh-experience 1.1.1 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,38 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [1.2.1] — 2026-11-09 — 审计修复:召回语料、id 回收、模拟淘汰、恢复、G8 守卫
5
+
6
+ ### 修复
7
+ - **召回漏传 IDF 语料**:`index.js` 召回处只传两个参数,`scoreExperience` 的第三参为空,所有词权重 1,常见词与稀有词同权——无关记录可能越过阈值被注入。现在与合并路径一致。
8
+ - **`nextId` 不与现有 id 对账**:载入时与 `experiences`/`archive` 的最大 `exp-N` 对齐,不再复用已存在的 id(否则 `forget`/`archive` 会打到旧记录)。
9
+ - **`decay --age N --apply` 用模拟未来时间真归档**:现在直接拒绝,`--age` 只用于展示。
10
+ - **`restore` 是空操作**:恢复时把 `lastUsedAt` 置为当前时刻,记录不会立刻重新判为 dormant。
11
+ - **淘汰会毁掉 G8 守卫**:`superseded` 不再进入淘汰候选(归档后同一指纹会被重新收录)。
12
+ - **“没什么可抓”被记成失败**:只有真的出现失败调用、且指纹与记录一致时,G3 拒绝才写 failure,普通对话不再毒化证据链。
13
+ - **临时文件名跨进程冲突**:`store.json.<pid>.tmp`,两个 DSH 进程同时写不再互相覆盖。
14
+ - **`pitfalls` 别名**:删除 `if (existing.pitfalls)`(`[]` 为真)导致 pitfalls 指向 reverse_examples 的写法。
15
+
16
+ ### 测试
17
+ - 新增 T87–T91、T63、T64;`node tools/selftest.mjs` 101 pass / 0 fail,`node tools/e2e.mjs` 25 pass / 0 fail。
18
+
19
+ ### 仍未做
20
+ - 工具在写盘失败时仍回“成功”(`persist` 吞异常);语料每轮仍重建 3 次;历史记录未回填 `pitfalls` 别名。
21
+
22
+ ## [1.2.0] — 2026-11-09 — 计划任务召回与已知问题清单
23
+
24
+ ### 修复
25
+ - **计划任务的轮次召回为空**:这类会话的第一条消息是 `[SCHEDULE REMINDER] … reminder_prompt_json: "…"`,
26
+ 整段被当成查询词,搜到的是 "schedule"。新增 `stripReminder()`,从包装里取出真实 prompt 再分词;
27
+ 原文保留在 `trace.queryRaw`。测试 T84。
28
+
29
+ ### 新增
30
+ - `docs/KNOWN-ISSUES.md`:八条已知问题,按"会不会让经验变错"排序,每条写症状/影响/现状/绕法
31
+ (含 shell 落盘不算写、`set_bundle` 不重载、召回只在第 1 步、纯词面检索、单机单用户等)。
32
+
33
+ ### 测试
34
+ - `selftest 94 pass / 0 fail`、`e2e 23 pass / 0 fail`。
35
+
4
36
  ## [1.1.1] — 2026-11-09 — 构建标识修正
5
37
 
6
38
  - `BUILD` 从 `r20-scope-tools` 改为 `r21-platform`。1.1.0 的代码已含平台隔离,但包内构建标识还是上一个版本号,
package/README.md CHANGED
@@ -150,6 +150,8 @@ plugin_manager install_bundle target=link:<PATH-TO-dsh-experience>
150
150
  - 淘汰只归档不删除,需要 `restore` 才能回到召回池;
151
151
  - 经验只描述「怎么做」,不包含业务代码本身。
152
152
 
153
+ 已知问题的完整清单(含症状、影响、绕法)见 [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md)。
154
+
153
155
  ## 开发
154
156
 
155
157
  ```bash
package/index.js CHANGED
@@ -35,7 +35,7 @@ const PROBE_FILE = path.join(STORE_DIR, 'probe.json')
35
35
  const THRESHOLD = 0.15
36
36
  const MAX_EXPERIENCES = 2
37
37
  const MAX_CHARS = 1600
38
- const BUILD = 'r21-platform'
38
+ const BUILD = 'r23-audit-fixes'
39
39
 
40
40
  let CONFIG = { ...C.DEFAULT_CONFIG }
41
41
 
@@ -286,11 +286,12 @@ export function apply(ctx) {
286
286
  const step = payload.step
287
287
  if (!id || step !== 1) return
288
288
 
289
- const queryText = (payload.messages || []).map(textOfMessage).join('\n')
289
+ const rawQuery = (payload.messages || []).map(textOfMessage).join('\n')
290
+ const queryText = R.stripReminder(rawQuery)
290
291
  const queryTokens = tokens(queryText)
291
292
  const index = R.corpus(store.experiences)
292
293
  const scored = store.experiences
293
- .map((experience) => ({ experience, score: scoreExperience(experience, queryTokens) }))
294
+ .map((experience) => ({ experience, score: scoreExperience(experience, queryTokens, index) }))
294
295
  .sort((a, b) => b.score - a.score)
295
296
  const candidates = scored.slice(0, 5).map(({ experience, score }) => ({
296
297
  id: experience.id,
@@ -310,6 +311,7 @@ export function apply(ctx) {
310
311
  endedAt: null,
311
312
  steps: 0,
312
313
  query: queryText.slice(0, 400),
314
+ queryRaw: rawQuery === queryText ? undefined : rawQuery.slice(0, 400),
313
315
  queryTokens: [...queryTokens].slice(0, 40),
314
316
  candidates,
315
317
  threshold: threshold(),
@@ -520,15 +522,23 @@ export function apply(ctx) {
520
522
 
521
523
  function noteGatedFailure(trace) {
522
524
  try {
525
+ const calls = trace.calls || []
526
+ // Being refused for "nothing to capture" is not a failure of any recipe.
527
+ // A turn only voids a record's evidence chain when it actually hit an error
528
+ // while attempting that same job.
529
+ if (!calls.some((call) => call && call.ok === false)) return
530
+ const fingerprint = C.outcomeFingerprint(calls)
523
531
  const queryTokens = tokens(trace.query || '')
524
532
  const index = R.corpus(store.experiences)
525
533
  let best = null
526
534
  for (const existing of store.experiences) {
527
535
  if (!['verified', 'auto-verified', 'candidate'].includes(existing.status)) continue
528
536
  const score = scoreExperience(existing, queryTokens, index)
529
- if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
537
+ if (score < CONFIG.mergeThreshold) continue
538
+ if (!C.fingerprintsMatch(existing, { fingerprint })) continue
539
+ if (!best || score > best.score) best = { existing, score }
530
540
  }
531
- if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(trace.calls || []))
541
+ if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(calls))
532
542
  } catch (error) {
533
543
  console.warn('[experience] failure evidence failed:', error && error.message)
534
544
  }
@@ -1101,7 +1111,10 @@ export function apply(ctx) {
1101
1111
  const ageIndex = parts.indexOf('--age')
1102
1112
  const simulatedDays = ageIndex >= 0 ? Number(parts[ageIndex + 1]) : 0
1103
1113
  const simulated = Number.isFinite(simulatedDays) && simulatedDays > 0
1104
- const now = simulated ? Date.now() + simulatedDays * 86400000 : Date.now()
1114
+ if (simulated && parts.includes('--apply')) {
1115
+ return err('--age 只用于模拟展示,不能和 --apply 一起用:先用 --age 看水位,再不带 --age 执行 --apply')
1116
+ }
1117
+ const now = Date.now()
1105
1118
  if (!parts.includes('--apply')) {
1106
1119
  D.markDecay(store.experiences, Date.now(), CONFIG)
1107
1120
  persist()
package/lib/capture.js CHANGED
@@ -723,7 +723,6 @@ export function mergeInto(existing, draft) {
723
723
  }
724
724
  }
725
725
  existing.reverse_examples = [...canonical, ...added].slice(0, 10)
726
- if (existing.pitfalls) existing.pitfalls = existing.reverse_examples
727
726
 
728
727
  const stepSeen = new Set(existing.steps || [])
729
728
  const newSteps = []
package/lib/decay.js CHANGED
@@ -127,7 +127,16 @@ export function selectEvictions(records, now = Date.now(), config = {}) {
127
127
  return { ...base, triggered: false, evictions: [], reason: `未达容量阈值 ${threshold}(当前 ${list.length})` }
128
128
  }
129
129
  const eligible = list
130
- .filter((record) => record && record.status !== 'verified' && record.status !== 'rejected' && !isPinned(record))
130
+ .filter(
131
+ (record) =>
132
+ record &&
133
+ record.status !== 'verified' &&
134
+ record.status !== 'rejected' &&
135
+ // Superseded records are the anti-re-adoption guard: archiving one lets the
136
+ // refused fingerprint be captured and recommended again.
137
+ record.status !== 'superseded' &&
138
+ !isPinned(record),
139
+ )
131
140
  .map((record) => ({ record, info: stateOf(record, now, config) }))
132
141
  .filter((entry) => entry.info.dormant)
133
142
  .sort((left, right) => left.info.rank - right.info.rank)
@@ -211,6 +220,9 @@ export function renderDecayReport(store, now = Date.now(), config = {}) {
211
220
  return lines.join('\n')
212
221
  }
213
222
 
223
+ export const ARCHIVE_LIMIT = 200
224
+ export const EVICTION_LIMIT = 500
225
+
214
226
  export function applyEvictions(store, now = Date.now(), config = {}, by = 'command') {
215
227
  const records = Array.isArray(store && store.experiences) ? store.experiences : []
216
228
  const proposal = selectEvictions(records, now, config)
@@ -240,6 +252,10 @@ export function applyEvictions(store, now = Date.now(), config = {}, by = 'comma
240
252
  ageDays: proposalItem ? proposalItem.ageDays : null,
241
253
  })
242
254
  }
255
+ // Archived records keep their whole body, so the list has to be bounded or the
256
+ // store grows forever on a long-lived machine.
257
+ if (store.archive.length > ARCHIVE_LIMIT) store.archive = store.archive.slice(-ARCHIVE_LIMIT)
258
+ if (store.evictions.length > EVICTION_LIMIT) store.evictions = store.evictions.slice(-EVICTION_LIMIT)
243
259
  return { archived: archived.map((record) => record.id), triggered: true, at, reason: `已归档 ${archived.length} 条(未物理删除,可用 /memory restore <id> 恢复)` }
244
260
  }
245
261
 
@@ -251,6 +267,9 @@ export function restoreRecord(store, id) {
251
267
  delete record.archivedAt
252
268
  delete record.archivedBy
253
269
  delete record.decay
270
+ // Resetting the snapshot alone would decay again on the next pass: age is
271
+ // measured from the last use, so a restore has to count as a use.
272
+ record.lastUsedAt = new Date(Date.now()).toISOString()
254
273
  store.experiences.push(record)
255
274
  store.evictions = Array.isArray(store.evictions) ? store.evictions : []
256
275
  store.evictions.push({ id, at: new Date().toISOString(), by: 'restore', reason: '人工恢复' })
package/lib/recall.js CHANGED
@@ -87,6 +87,29 @@ export function expand(text) {
87
87
  return out
88
88
  }
89
89
 
90
+ /**
91
+ * A scheduled turn starts with its delivery wrapper ("[SCHEDULE REMINDER] ...
92
+ * reminder_prompt_json: \"...\""). Searching that text finds the word
93
+ * "schedule" and nothing else, so recall reads the wrapped prompt instead.
94
+ */
95
+ export function stripReminder(text) {
96
+ const raw = String(text || '')
97
+ if (!raw.includes('[SCHEDULE REMINDER]')) return raw
98
+ const spoiled = /reminder_prompt_json:\s*("(?:[^"\\]|\\.)*")/s.exec(raw)
99
+ if (spoiled) {
100
+ try {
101
+ const prompt = JSON.parse(spoiled[1])
102
+ if (prompt) return String(prompt)
103
+ } catch {
104
+ /* keep the raw text when the JSON is not what we expect */
105
+ }
106
+ }
107
+ return raw
108
+ .split('\n')
109
+ .filter((line) => !/^\s*\[SCHEDULE REMINDER\]/.test(line) && !/^\s*(schedule_id_json|occurrence_at|reminder_prompt_json)\s*:/.test(line))
110
+ .join('\n')
111
+ }
112
+
90
113
  /** The searchable profile of a stored experience. */
91
114
  export function profileText(experience) {
92
115
  if (!experience) return ''
package/lib/store.js CHANGED
@@ -1,15 +1,47 @@
1
- // Durable store I/O, split out of index.js so the reload path is testable.
2
- //
3
- // The bug this exists to prevent: `loadStore()` used to rebuild only
4
- // {version,nextId,nextInjectionId,experiences,injections,turns}, so every
5
- // reload dropped `archive` and `evictions` — and the next persist() rewrote
6
- // them out of the file, silently losing archived records.
1
+ // A turn persists the store several times (injection bookkeeping, capture, decay,
2
+ // trace close). Each write serialises the whole file, and by now the file carries
3
+ // a hundred turns, so identical rewrites are pure waste. Two cheap guards:
4
+ // 1. skip the write when the serialised payload equals the last one written;
5
+ // 2. keep a counter so the tests can prove it, instead of trusting mtime.
7
6
 
8
7
  import fs from 'node:fs'
9
8
 
9
+ const lastWritten = new Map()
10
+ const stats = { writes: 0, skipped: 0 }
11
+
12
+ export function writeStore(file, store) {
13
+ const text = JSON.stringify(store, null, 2)
14
+ // Same bytes as the last successful write to this path means nothing changed;
15
+ // several listeners can persist the same snapshot within one turn.
16
+ if (lastWritten.get(file) === text) {
17
+ stats.skipped += 1
18
+ return { written: false }
19
+ }
20
+ const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
21
+ if (dir) fs.mkdirSync(dir, { recursive: true })
22
+ const tmp = `${file}.${process.pid}.tmp`
23
+ fs.writeFileSync(tmp, text, 'utf8')
24
+ fs.renameSync(tmp, file)
25
+ lastWritten.set(file, text)
26
+ stats.writes += 1
27
+ return { written: true }
28
+ }
29
+
30
+ /** Exposed for tests; also handy when diagnosing write amplification. */
31
+ export function writeStats() {
32
+ return { ...stats }
33
+ }
34
+
35
+ /** Tests need a clean slate between fixtures. */
36
+ export function resetWriteCache() {
37
+ lastWritten.clear()
38
+ stats.writes = 0
39
+ stats.skipped = 0
40
+ }
41
+
10
42
  export function emptyStore() {
11
43
  return {
12
- version: 1,
44
+ version: 2,
13
45
  nextId: 1,
14
46
  nextInjectionId: 1,
15
47
  experiences: [],
@@ -27,11 +59,18 @@ const array = (value) => (Array.isArray(value) ? value : [])
27
59
  * Unknown extra fields are preserved verbatim on purpose: a plugin upgrade that
28
60
  * adds a table must not lose it when an older build reloads the file.
29
61
  */
62
+ const highestId = (list) =>
63
+ list.reduce((max, record) => {
64
+ const match = record && typeof record.id === 'string' ? /^exp-(\d+)$/.exec(record.id) : null;
65
+ return match ? Math.max(max, Number(match[1])) : max;
66
+ }, 0);
67
+
30
68
  export function normalizeStore(parsed) {
31
69
  if (!parsed || typeof parsed !== 'object') throw new Error('not an object')
32
70
  const known = {
33
71
  version: Math.max(2, Number(parsed.version || 1)),
34
- nextId: Number(parsed.nextId) || 1,
72
+ // A hand-edited or truncated store must never hand out an id that already exists.
73
+ nextId: Math.max(Number(parsed.nextId) || 1, highestId([...array(parsed.experiences), ...array(parsed.archive)]) + 1),
35
74
  nextInjectionId: Number(parsed.nextInjectionId) || 1,
36
75
  experiences: array(parsed.experiences),
37
76
  injections: array(parsed.injections),
@@ -49,18 +88,12 @@ export function normalizeStore(parsed) {
49
88
  export function readStore(file) {
50
89
  try {
51
90
  const raw = fs.readFileSync(file, 'utf8')
52
- return { store: normalizeStore(JSON.parse(raw)) }
91
+ const store = normalizeStore(JSON.parse(raw))
92
+ // Seed the cache so a read followed by an unchanged write is skipped too.
93
+ lastWritten.set(file, JSON.stringify(store, null, 2))
94
+ return { store }
53
95
  } catch (error) {
54
96
  if (error && error.code === 'ENOENT') return { store: emptyStore(), missing: true }
55
97
  return { store: emptyStore(), error }
56
98
  }
57
- }
58
-
59
- /** Atomic-ish write: temp file + rename, UTF-8, no BOM. */
60
- export function writeStore(file, store) {
61
- const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
62
- if (dir) fs.mkdirSync(dir, { recursive: true })
63
- const tmp = `${file}.tmp`
64
- fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8')
65
- fs.renameSync(tmp, file)
66
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-experience",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/tools/e2e.mjs CHANGED
@@ -410,6 +410,23 @@ await test('T62 the same recipe in a second project stays separate', async () =>
410
410
  assert.match(String(second.reason), /scope 不同|意图不同|按新任务/, `reason: ${second.reason}`)
411
411
  })
412
412
 
413
+ await test('T63 --age is a simulation and refuses to combine with --apply', async () => {
414
+ const out = await cmd('decay --age 400 --apply');
415
+ assert.equal(out.kind, 'error', out.text);
416
+ assert.match(out.text, /不能和 --apply 一起用/);
417
+ });
418
+
419
+ await test('T64 a refused turn without errors does not void evidence', async () => {
420
+ const before = JSON.stringify(readStore().experiences.map((e) => e.proof || null));
421
+ await enter(A, 40, 1, '在 acme-app 里新增 pad 函数并配 node:test 用例');
422
+ await call(A, 'read', { file_path: 'D:\\work\\acme-app\\src\\pad.js' });
423
+ await call(A, 'read', { file_path: 'D:\\work\\acme-app\\test\\pad.test.mjs' });
424
+ await fire('agent/turn-stopping', { agent: A, turn: 40, signal: undefined });
425
+ const capture = readStore().turns.at(-1).capture;
426
+ assert.equal(capture.ok, false, JSON.stringify(capture));
427
+ assert.equal(JSON.stringify(readStore().experiences.map((e) => e.proof || null)), before, 'no failure evidence');
428
+ });
429
+
413
430
  console.log('');
414
431
  let pass = 0;
415
432
  results.forEach((item, index) => {
@@ -1198,6 +1198,95 @@ test('T83 a recipe from another OS is never merged and never recalled', () => {
1198
1198
  assert.equal(C.platformConflicts({}), false)
1199
1199
  })
1200
1200
 
1201
+ test('T84 a scheduled wrapper yields the task, not the wrapper', () => {
1202
+ const wrapped = '[SCHEDULE REMINDER]\nThis is a scheduled message from the user\nschedule_id_json: "schedule-1"\noccurrence_at: 2026-11-09T02:57:00.000Z\nreminder_prompt_json: "在 D:\\\\work\\\\platgate 下创建 platgate.js 并运行"'
1203
+ const task = R.stripReminder(wrapped)
1204
+ assert.equal(task, '在 D:\\work\\platgate 下创建 platgate.js 并运行')
1205
+ const tokens = R.expand(task)
1206
+ assert.ok(tokens.has('platgate'))
1207
+ assert.ok(!tokens.has('schedule'))
1208
+ assert.equal(R.stripReminder('普通任务'), '普通任务')
1209
+ })
1210
+
1211
+ test('T85 an unchanged store is not rewritten', () => {
1212
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-store-'))
1213
+ const file = path.join(dir, 'store.json')
1214
+ S.resetWriteCache()
1215
+ const store = S.emptyStore()
1216
+ assert.equal(S.writeStore(file, store).written, true)
1217
+ assert.equal(S.writeStore(file, store).written, false)
1218
+ store.nextId = 7
1219
+ assert.equal(S.writeStore(file, store).written, true)
1220
+ const stats = S.writeStats()
1221
+ assert.equal(stats.writes, 2)
1222
+ assert.equal(stats.skipped, 1)
1223
+ // A read seeds the cache, so "load then persist an untouched snapshot" is free.
1224
+ S.resetWriteCache()
1225
+ const reread = S.readStore(file)
1226
+ assert.equal(S.writeStore(file, reread.store).written, false)
1227
+ fs.rmSync(dir, { recursive: true, force: true })
1228
+ })
1229
+
1230
+ test('T86 the archive keeps the newest 200 records', () => {
1231
+ assert.equal(D.ARCHIVE_LIMIT, 200)
1232
+ const store = { experiences: [], archive: [], evictions: [], nextId: 1 }
1233
+ for (let i = 0; i < 205; i += 1) {
1234
+ store.experiences.push({ id: 'exp-' + i, status: 'candidate', createdAt: '2020-01-01T00:00:00.000Z', steps: ['a', 'b'] })
1235
+ }
1236
+ const result = D.applyEvictions(store, Date.now(), { ...C.DEFAULT_CONFIG, libraryLimit: 10 }, 'test')
1237
+ assert.ok(result.archived.length > 0)
1238
+ assert.ok(store.archive.length <= D.ARCHIVE_LIMIT)
1239
+ assert.equal(store.archive.at(-1).archivedBy, 'test')
1240
+ assert.ok(store.evictions.length <= D.EVICTION_LIMIT)
1241
+ })
1242
+
1243
+ test('T87 recall scores with the IDF corpus', () => {
1244
+ const source = fs.readFileSync(new URL('../index.js', import.meta.url), 'utf8')
1245
+ assert.ok(source.includes('scoreExperience(experience, queryTokens, index)'), 'recall passes the corpus')
1246
+ assert.ok(!source.includes('scoreExperience(experience, queryTokens)'), 'no call site drops the corpus')
1247
+ })
1248
+
1249
+ test('T88 a stale nextId is reconciled with existing ids', () => {
1250
+ const store = S.normalizeStore({
1251
+ nextId: 3,
1252
+ experiences: [{ id: 'exp-1' }, { id: 'exp-9' }],
1253
+ archive: [{ id: 'exp-12' }],
1254
+ })
1255
+ assert.equal(store.nextId, 13)
1256
+ const empty = S.normalizeStore({ experiences: [] })
1257
+ assert.equal(empty.nextId, 1)
1258
+ })
1259
+
1260
+ test('T89 superseded records are never evicted', () => {
1261
+ const now = Date.parse('2026-11-09T00:00:00Z')
1262
+ const old = new Date(now - 400 * 86400000).toISOString()
1263
+ const records = []
1264
+ for (let i = 0; i < 159; i += 1) {
1265
+ records.push({ id: 'exp-' + (100 + i), status: 'candidate', createdAt: old })
1266
+ }
1267
+ records.push({ id: 'exp-super', status: 'superseded', createdAt: old })
1268
+ const result = D.selectEvictions(records, now, {})
1269
+ assert.equal(result.triggered, true)
1270
+ assert.ok(result.evictions.length > 0, 'old candidates are proposed')
1271
+ assert.ok(!result.evictions.some((item) => item.id === 'exp-super'), 'the G8 guard stays in the library')
1272
+ })
1273
+
1274
+ test('T90 restore makes a record young again', () => {
1275
+ const now = Date.parse('2026-11-09T00:00:00Z')
1276
+ const old = new Date(now - 400 * 86400000).toISOString()
1277
+ const store = { experiences: [], archive: [{ id: 'exp-old', status: 'candidate', createdAt: old, steps: [] }], evictions: [] }
1278
+ const restored = D.restoreRecord(store, 'exp-old')
1279
+ assert.ok(restored, 'record comes back')
1280
+ D.markDecay(store.experiences, now, {})
1281
+ const decay = store.experiences[0].decay
1282
+ assert.equal(decay.state, 'active')
1283
+ assert.equal(decay.ageDays, 0)
1284
+ })
1285
+
1286
+ test('T91 a fresh store starts at schema version 2', () => {
1287
+ assert.equal(S.emptyStore().version, 2)
1288
+ })
1289
+
1201
1290
  // ---------------------------------------------------------------- summary
1202
1291
 
1203
1292
  const total = pass + failures.length