dsh-experience 1.2.1 → 1.2.2

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,29 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [1.2.2] — 2026-11-09 — 体验修复:采集可靠性、候选可读性、旧经验降权
5
+
6
+ **采集**
7
+ - `tools/result` 与 turn 生命周期统一用 session id 对齐(原先工具侧取 `agent.id`、轮末取 `session.id`,不一致时整轮调用被记到另一个 key,表现为 `G1: 成功工具调用 < 2` 而实际跑了很多工具)。
8
+ - 单 key 兜底搬运 + `trace.resultsSeen` / `trace.keyFallback`;G1 拒绝但确实收到过结果时写入 `capture.diagnostic`,不再静默。
9
+
10
+ **召回**
11
+ - 无 platform 标签的历史记录排序 ×0.85,注入块里标注「平台:未标注」。
12
+ - `score()` 收到非字符串直接抛 `TypeError`(此前把记录对象传进去会静默得 0,看起来像"不相关")。
13
+
14
+ **候选可读性**
15
+ - 标题带项目名:`新增 truncate(acme-app)`、`跑 run(cross-a)`。
16
+ - 触发条件写真实验证命令,不再三条候选共用一句模板。
17
+ - `stepLabel` 去掉 `cd …;` 前缀;`cd` / `Start-Sleep` 计入噪声。
18
+ - 新增 `/memory review discard`:一次性拒绝当前所有候选(可审计,非删除)。
19
+
20
+ **衰减**
21
+ - 容量未达阈值时不再输出"观察项"清单(小库时那 5 条就是全部,纯噪声)。
22
+
23
+ **测试**:`selftest 106` + `e2e 25` = 131 全部通过。
24
+
25
+ **已知限制**:采集可靠性改动只经过单测与结构验证;真实轮次的效果需要下一个长轮次的 `capture.diagnostic` 来确认。
26
+
4
27
  ## [1.2.1] — 2026-11-09 — 审计修复:召回语料、id 回收、模拟淘汰、恢复、G8 守卫
5
28
 
6
29
  ### 修复
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 = 'r23-audit-fixes'
38
+ const BUILD = 'r24-ux-fixes'
39
39
 
40
40
  let CONFIG = { ...C.DEFAULT_CONFIG }
41
41
 
@@ -111,8 +111,21 @@ function tokens(text) {
111
111
  return R.expand(text)
112
112
  }
113
113
 
114
+ /**
115
+ * Sessions own tool calls; an agent object can lose its id between the tool
116
+ * lifecycle and the turn lifecycle. Keying on the session keeps both sides in
117
+ * agreement — the mismatch was silently dropping every call of a turn.
118
+ */
119
+ function keyOf(agent) {
120
+ if (!agent) return ''
121
+ return (agent.session && agent.session.id) || agent.id || ''
122
+ }
123
+
114
124
  function scoreExperience(experience, queryTokens, index) {
115
- return R.score(R.profileText(experience), queryTokens, index)
125
+ const base = R.score(R.profileText(experience), queryTokens, index)
126
+ // A recipe learned on another machine, or never tagged at all, is weaker evidence.
127
+ const factor = C.platformFactor(experience)
128
+ return Math.round(base * factor * 1000) / 1000
116
129
  }
117
130
 
118
131
  // Retrieval floor. Configurable via "/memory config set recallThreshold 0.1"
@@ -239,6 +252,7 @@ export function apply(ctx) {
239
252
  const traces = new Map()
240
253
  const bound = new Map()
241
254
  const liveCalls = new Map()
255
+ const resultsSeen = new Map()
242
256
  const probe = { done: false }
243
257
  let currentTrace = null
244
258
 
@@ -259,6 +273,8 @@ export function apply(ctx) {
259
273
  lines.push('')
260
274
  lines.push(`## ${experience.id} · ${experience.title}`)
261
275
  if (experience.when) lines.push(`适用:${experience.when}`)
276
+ // Untagged records predate platform isolation; say so rather than pretend.
277
+ if (!C.platformOf(experience)) lines.push('平台:未标注(旧经验,按通用配方参考)')
262
278
  const steps = (experience.steps || []).slice(0, 6)
263
279
  steps.forEach((step, index) => lines.push(`${index + 1}. ${step}`))
264
280
  const pitfalls = experience.pitfalls || experience.reverse_examples || []
@@ -475,8 +491,9 @@ export function apply(ctx) {
475
491
  function recordCall(exec, result) {
476
492
  try {
477
493
  ensureProbe(exec, result)
478
- const id = agentId(exec && exec.agent) || initiatorId()
494
+ const id = keyOf(exec && exec.agent) || initiatorId()
479
495
  if (!id) return
496
+ resultsSeen.set(id, (resultsSeen.get(id) || 0) + 1)
480
497
  const ok = pickOk(exec, result)
481
498
  const list = liveCalls.get(id) || []
482
499
  if (list.length >= 80) list.shift()
@@ -732,15 +749,31 @@ export function apply(ctx) {
732
749
 
733
750
  ctx.on('agent/turn-stopping', (payload) => {
734
751
  try {
735
- const id = agentId(payload && payload.agent)
736
- const calls = liveCalls.get(id) || []
752
+ const id = keyOf(payload && payload.agent)
753
+ let calls = liveCalls.get(id) || []
737
754
  liveCalls.delete(id)
755
+ let keyFallback = false
756
+ if (!calls.length && liveCalls.size === 1) {
757
+ const [otherId, otherCalls] = liveCalls.entries().next().value
758
+ if (otherCalls && otherCalls.length) {
759
+ calls = otherCalls
760
+ liveCalls.delete(otherId)
761
+ keyFallback = true
762
+ }
763
+ }
764
+ const seen = resultsSeen.get(id) || 0
765
+ resultsSeen.delete(id)
738
766
  const trace = traces.get(id)
739
767
  if (!trace || trace.turn !== payload.turn) return
740
768
  trace.calls = calls.slice(-CONFIG.maxCalls)
769
+ trace.resultsSeen = seen
770
+ if (keyFallback) trace.keyFallback = true
741
771
  trace.endedAt = new Date().toISOString()
742
772
  trace.durationMs = Date.parse(trace.endedAt) - Date.parse(trace.startedAt)
743
773
  const capture = runCapture(trace, payload.agent)
774
+ if (capture && !capture.ok && /^G1/.test(String(capture.reason)) && seen > trace.calls.length) {
775
+ capture.diagnostic = `采集异常:本轮收到 ${seen} 次工具结果,只记录 ${trace.calls.length} 条${keyFallback ? '(已用 keyFallback 兜底)' : ''}`
776
+ }
744
777
  // Raw hints are needed while capturing (scope + fingerprint); they must not
745
778
  // reach the durable trace, so collapse them to the redacted form now.
746
779
  trace.calls = (trace.calls || []).map((call) => ({ ...call, hint: C.clip(call.hint, 120) }))
@@ -982,6 +1015,17 @@ export function apply(ctx) {
982
1015
  }),
983
1016
  ].join('\n')
984
1017
  }
1018
+ if (line === 'discard' || line === '--discard' || line === 'reject-all') {
1019
+ const candidates = C.reviewTargets(store.experiences)
1020
+ if (!candidates.length) return '没有待确认的候选经验。'
1021
+ const at = new Date().toISOString()
1022
+ for (const item of candidates) {
1023
+ item.status = 'rejected'
1024
+ item.verify = { by: 'human', at, method: 'command-reject-all' }
1025
+ }
1026
+ persist()
1027
+ return `已拒绝 ${candidates.length} 条候选:${candidates.map((item) => item.id).join(', ')}`
1028
+ }
985
1029
  if (line === '--approve-all' || line === 'approve-all' || line === 'approve --all') {
986
1030
  const candidates = C.reviewTargets(store.experiences)
987
1031
  if (!candidates.length) return '没有待确认的候选经验。'
package/lib/capture.js CHANGED
@@ -120,6 +120,18 @@ export function classify(call) {
120
120
  return { kind, target, name }
121
121
  }
122
122
 
123
+ /**
124
+ * "cd somewhere; node test.js" says nothing about the recipe beyond the command
125
+ * that actually ran, so keep only the trailing command.
126
+ */
127
+ export function stripCdPrefix(command) {
128
+ const text = String(command || '').trim()
129
+ const parts = text.split(/\s*(?:;|&&|\|\|)\s*/).filter(Boolean)
130
+ if (parts.length <= 1) return text.replace(/;+$/, '')
131
+ const kept = parts.filter((part) => !/^cd\s/i.test(part) && !/^Set-Location\b/i.test(part))
132
+ return (kept.length ? kept.join('; ') : parts[parts.length - 1]).replace(/;+$/, '')
133
+ }
134
+
123
135
  export function stepLabel(call) {
124
136
  const { kind, target, name } = classify(call)
125
137
  switch (kind) {
@@ -128,7 +140,7 @@ export function stepLabel(call) {
128
140
  case 'read':
129
141
  return target ? `读 ${target.split('/').pop()}` : '读文件'
130
142
  case 'run':
131
- return target ? `跑 ${target}` : '执行命令'
143
+ return target ? `跑 ${stripCdPrefix(target)}` : '执行命令'
132
144
  case 'search':
133
145
  return target ? `检索 ${target}` : '检索'
134
146
  default:
@@ -172,7 +184,7 @@ export function stepPriority(call) {
172
184
  * output) is transcript noise: it describes how the agent measured the world,
173
185
  * not what a second run should do.
174
186
  */
175
- const NOISE_PREFIX = /^(\$|New-Item|New-ItemProperty|New-Object|Test-Path|Get-ChildItem|Get-Item|Set-Location|Push-Location|Pop-Location|Write-Host|Write-Output|Copy-Item|Remove-Item|Move-Item|Rename-Item)\b/
187
+ const NOISE_PREFIX = /^(\$|New-Item|New-ItemProperty|New-Object|Test-Path|Get-ChildItem|Get-Item|Set-Location|Push-Location|Pop-Location|Write-Host|Write-Output|Write-Verbose|Clear-Host|Copy-Item|Remove-Item|Move-Item|Rename-Item|Start-Sleep|sleep|cd)\b/
176
188
  const ASSIGNMENT = /^[A-Za-z_$][\w$]*\s*=[^=]/
177
189
 
178
190
  export function isNoiseStep(label) {
@@ -541,6 +553,10 @@ const GENERIC_DIRS = new Set([
541
553
  // say nothing about *which project* a record belongs to.
542
554
  'node', 'python', 'python3', 'npm', 'pnpm', 'npx', 'yarn', 'git', 'pwsh',
543
555
  'deno', 'bun', 'runtime', 'runtimes', 'dependencies', 'dsh', 'windows',
556
+ // Home-directory placeholders: a user name is not a project name.
557
+ 'dev', 'developer', 'user', 'userprofile',
558
+ // Sandbox and CI roots: fixtures live here, they are not project names.
559
+ 'work', 'projects', 'repos',
544
560
  ])
545
561
 
546
562
  /**
@@ -636,22 +652,46 @@ export function buildCandidate(trace, info, config) {
636
652
  const named = writes
637
653
  .map((call) => artifactName(call))
638
654
  .find((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) && /[A-Za-z0-9]{2,}/.test(name))
655
+ // Scope is computed before rendering so the title and the trigger can name the
656
+ // project. "新增 index" is unreadable in a review list; "新增 index(dsh-experience)"
657
+ // tells the human which project the recipe belongs to.
658
+ const scope = mentionedDirs(
659
+ [
660
+ ...(calls || []).map((call) => firstString(call && (call.hint || call.argsHint || call.args))),
661
+ trace && trace.query,
662
+ ].join(' '),
663
+ )
664
+ const project = projectScope({ scope })[0] || ''
665
+ const suffix = project ? `(${project})` : ''
639
666
  const title = clip(
640
667
  runOnly
641
668
  ? scriptName
642
- ? `跑 ${scriptName}`
669
+ ? `跑 ${scriptName}${suffix}`
643
670
  : primary
644
671
  ? `跑 ${primary}`
645
672
  : verbsOf(taskType)
646
673
  : named
647
- ? `新增 ${named}`
674
+ ? `新增 ${named}${suffix}`
648
675
  : primary
649
676
  ? `${verb} ${primary}`
650
677
  : verbsOf(taskType),
651
678
  110,
652
679
  )
653
680
  const place = workspaceName(info && info.cwd)
654
- const when = clip(place ? `在 ${place} 里 ${verbsOf(taskType)}` : `当需要${verbsOf(taskType)}时`, 160)
681
+ const testRun = runs.find((call) => TEST_COMMAND.test(firstString(call && (call.hint || call.argsHint || call.args))))
682
+ const testCommand = testRun ? classify(testRun).target : ''
683
+ // The trigger line must discriminate between recipes, not repeat the task type
684
+ // ("在 desktop 里 新增函数/模块并配用例" fits every single candidate).
685
+ const when = clip(
686
+ project
687
+ ? `在 ${project} 里 ${verbsOf(taskType)}${testCommand ? `,验证 ${testCommand}` : ''}`
688
+ : testCommand
689
+ ? `当需要${verbsOf(taskType)}时,验证 ${testCommand}`
690
+ : place
691
+ ? `在 ${place} 里 ${verbsOf(taskType)}`
692
+ : `当需要${verbsOf(taskType)}时`,
693
+ 160,
694
+ )
655
695
  const keywords = [...new Set([taskType, ...steps.map((step) => step.split(' ')[1] || '').filter(Boolean)])]
656
696
  .filter(Boolean)
657
697
  .slice(0, 12)
@@ -660,13 +700,11 @@ export function buildCandidate(trace, info, config) {
660
700
  const record = {
661
701
  status: 'candidate',
662
702
  platform: process.platform,
703
+ // Task shape as recorded, not as re-guessed from prose later: a write task and
704
+ // a "run this script" task must never merge even when they share every noun.
705
+ intent: runOnly ? 'run' : 'write',
663
706
  // Scope detection needs the raw paths; every rendered field below is normalized.
664
- scope: mentionedDirs(
665
- [
666
- ...(calls || []).map((call) => firstString(call && (call.hint || call.argsHint || call.args))),
667
- trace && trace.query,
668
- ].join(' '),
669
- ),
707
+ scope,
670
708
  fingerprint: outcomeFingerprint(calls),
671
709
  title,
672
710
  taskType,
@@ -703,6 +741,16 @@ export function platformOf(record) {
703
741
  * darwin. Unknown platform never vetoes: records written before v1.1 carry no
704
742
  * tag, and every one of them was learned on this machine.
705
743
  */
744
+ /**
745
+ * Old records carry no platform tag: they were learned before isolation existed.
746
+ * They stay usable, but must not outrank a record that was proven on this machine.
747
+ */
748
+ export function platformFactor(record) {
749
+ const tag = platformOf(record)
750
+ if (!tag) return 0.85
751
+ return tag === process.platform ? 1 : 0
752
+ }
753
+
706
754
  export function platformConflicts(record) {
707
755
  const tag = platformOf(record)
708
756
  return Boolean(tag && tag !== process.platform)
package/lib/decay.js CHANGED
@@ -209,12 +209,14 @@ export function renderDecayReport(store, now = Date.now(), config = {}) {
209
209
  `[M3.3 衰减报告] 库 ${live.length} 条 · active ${counts.active} / dormant ${counts.dormant} / pinned ${counts.pinned}`,
210
210
  ...(hiddenRejected ? [`已排除 ${hiddenRejected} 条 rejected(审计留档,不参与排序与容量;\/memory list --all 查看)`] : []),
211
211
  `容量: ${proposal.count}/${proposal.libraryLimit}(阈值 ${proposal.threshold} · 比例 ${num(config.capacityRatio, DECAY_DEFAULTS.capacityRatio)})· ${proposal.triggered ? '已达阈值' : '未达阈值'}`,
212
- proposal.triggered
213
- ? `建议归档 ${proposal.evictions.length} 条(最多 ${num(config.evictBatch, DECAY_DEFAULTS.evictBatch)} 条/次,按 rank 升序):`
214
- : '容量尚未告警: 以下为 rank 最低的 5 条观察项,无需处理。',
215
- ...(top.length
216
- ? top.map((entry, index) => ` ${index + 1}. ${decayLine(entry.record, now, config)}${eligible.has(entry.record.id) ? ' ← 可归档' : ''}`)
217
- : [' (库为空)']),
212
+ ...(proposal.triggered
213
+ ? [
214
+ `建议归档 ${proposal.evictions.length} 条(最多 ${num(config.evictBatch, DECAY_DEFAULTS.evictBatch)} 条/次,按 rank 升序):`,
215
+ ...(top.length
216
+ ? top.map((entry, index) => ` ${index + 1}. ${decayLine(entry.record, now, config)}${eligible.has(entry.record.id) ? ' ← 可归档' : ''}`)
217
+ : [' (库为空)']),
218
+ ]
219
+ : [`容量充足(${proposal.count}/${proposal.libraryLimit}),暂时不需要淘汰任何记录。`]),
218
220
  '只列出建议,不删除任何记录。确认后运行 /memory decay --apply(移入 archive,可用 /memory restore <id> 恢复)。',
219
221
  ]
220
222
  return lines.join('\n')
package/lib/recall.js CHANGED
@@ -138,6 +138,11 @@ const round = (value) => Math.round(value * 1000) / 1000
138
138
  * Empty means "cannot tell" and never vetoes anything.
139
139
  */
140
140
  export function intentOf(experienceOrText) {
141
+ // Records written by buildCandidate carry the intent they were captured with.
142
+ // Re-deriving it from prose is only the fallback for hand-written records.
143
+ if (experienceOrText && typeof experienceOrText === 'object' && (experienceOrText.intent === 'run' || experienceOrText.intent === 'write')) {
144
+ return experienceOrText.intent
145
+ }
141
146
  const text =
142
147
  typeof experienceOrText === 'string' ? experienceOrText : profileText(experienceOrText)
143
148
  const found = features(text)
@@ -207,6 +212,11 @@ const scriptWeight = (token) => (/^[\u4e00-\u9fff]+$/.test(token) ? 0.45 : 1)
207
212
  * Both terms are bounded, so the result is always within [0, 1].
208
213
  */
209
214
  export function score(profile, queryTokens, c) {
215
+ // Passing a record here used to score 0 silently, which looks exactly like
216
+ // "this experience is irrelevant" from the outside. Fail loudly instead.
217
+ if (typeof profile !== 'string') {
218
+ throw new TypeError('score() expects plain text; call profileText(record) first')
219
+ }
210
220
  const expTokens = expand(profile)
211
221
  if (!queryTokens || !queryTokens.size || !expTokens.size) return 0
212
222
  let weightSum = 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-experience",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -341,7 +341,7 @@ test('buildCandidate: shape, taskType, steps, evidence', () => {
341
341
  assert.equal(draft.source.auto, true)
342
342
  assert.equal(draft.verify, null)
343
343
  assert.equal(draft.occurrences, 1)
344
- assert.equal(draft.title, '新增 truncate')
344
+ assert.equal(draft.title, '新增 truncate(acme-app)')
345
345
  assert.ok(draft.when.includes('acme-app'), draft.when)
346
346
  assert.deepEqual(draft.steps, ['写 src/<name>.js', '跑 node test/<name>.mjs'])
347
347
  assert.equal(draft.evidence, 'session-x:turn-3 · calls=3 errors=0')
@@ -710,7 +710,9 @@ test('T30: decayLine and decay report expose rank/age and stay proposal-only', (
710
710
  assert.ok(report.includes('只列出建议,不删除任何记录'), report)
711
711
  assert.ok(report.includes('/memory decay --apply'), report)
712
712
  assert.ok(report.includes('/memory restore <id>'), report)
713
- assert.equal(report.split('\n').filter((row) => /^ {2}\d+\. /.test(row)).length, 2, 'pinned is excluded from the ranking')
713
+ // Under capacity there is nothing to rank: no observation list at all.
714
+ assert.ok(report.includes('容量充足'), report)
715
+ assert.equal(report.split('\n').filter((row) => /^ {2}\d+\. /.test(row)).length, 0, 'no observation list under capacity')
714
716
  const many = [...records, ...Array.from({ length: 160 }, (_, i) => ({ id: `f-${i}`, status: 'candidate', lastUsedAt: ago(1) }))]
715
717
  const hot = D.renderDecayReport({ experiences: many }, NOW)
716
718
  assert.ok(hot.includes('已达阈值'), hot)
@@ -814,7 +816,7 @@ test('T34: a real recipe still passes G7 and gets an actionable title', () => {
814
816
  const gate = C.gateStepQuality(trace, draft, C.DEFAULT_CONFIG)
815
817
  assert.equal(gate.ok, true, gate.reason)
816
818
  assert.equal(gate.effective, 2)
817
- assert.equal(draft.title, '新增 truncate')
819
+ assert.equal(draft.title, '新增 truncate(acme-app)')
818
820
  assert.equal(C.actionableTitle(draft.title), true)
819
821
  })
820
822
 
@@ -1135,7 +1137,7 @@ test('T78 a pure run track is captured, titled and fingerprinted by its command'
1135
1137
  assert.equal(quality.ok, true, quality.reason)
1136
1138
 
1137
1139
  const draft = C.buildCandidate(trace, { runOnly: true }, C.DEFAULT_CONFIG)
1138
- assert.equal(draft.title, '跑 run')
1140
+ assert.equal(draft.title, '跑 run(cross-a)')
1139
1141
  assert.deepEqual(draft.fingerprint.commands, ['node'])
1140
1142
  assert.deepEqual(C.commandHeads(calls), ['node'])
1141
1143
  })
@@ -1287,6 +1289,41 @@ test('T91 a fresh store starts at schema version 2', () => {
1287
1289
  assert.equal(S.emptyStore().version, 2)
1288
1290
  })
1289
1291
 
1292
+ // ---------------------------------------------------------------- v1.2.2
1293
+
1294
+ test('score: a non-string profile fails loudly instead of scoring 0', () => {
1295
+ assert.throws(() => R.score({ title: 'x' }, R.expand('新增函数'), R.corpus([])), /profileText/)
1296
+ assert.equal(typeof R.score('新增 pad 函数', R.expand('新增 pad 函数'), R.corpus([])), 'number')
1297
+ })
1298
+
1299
+ test('platformFactor: untagged records are discounted, foreign ones excluded', () => {
1300
+ assert.equal(C.platformFactor({}), 0.85)
1301
+ assert.equal(C.platformFactor({ platform: process.platform }), 1)
1302
+ assert.equal(C.platformFactor({ platform: process.platform === 'win32' ? 'linux' : 'win32' }), 0)
1303
+ })
1304
+
1305
+ test('stepLabel: hangs off the command, not the scaffolding in front of it', () => {
1306
+ const label = C.stepLabel({ name: 'pwsh', hint: 'cd D:\\work\\acme-app; node test\\pad.test.mjs', ok: true })
1307
+ assert.ok(!label.includes('cd '), label)
1308
+ assert.ok(label.includes('node'), label)
1309
+ assert.ok(C.isNoiseStep('cd D:\\work'), 'a bare cd is not a step')
1310
+ assert.ok(C.isNoiseStep('Start-Sleep -Seconds 2'), 'waiting is not a step')
1311
+ })
1312
+
1313
+ test('buildCandidate: when names the project and the verification command', () => {
1314
+ const draft = C.buildCandidate(sampleTrace(), { cwd: 'D:\\work\\acme-app', sessionId: 'session-z' })
1315
+ assert.ok(draft.when.includes('acme-app'), draft.when)
1316
+ assert.ok(draft.when.includes('node'), draft.when)
1317
+ assert.notEqual(draft.when, '在 desktop 里 新增函数/模块并配用例')
1318
+ })
1319
+
1320
+ test('decay report stays quiet while the library is nowhere near capacity', () => {
1321
+ const records = [{ id: 'exp-1', status: 'verified', title: 'x', uses: 1, at: new Date(NOW).toISOString() }]
1322
+ const report = D.renderDecayReport({ experiences: records }, NOW, { libraryLimit: 200 })
1323
+ assert.ok(report.includes('容量充足'), report)
1324
+ assert.ok(!report.includes('观察项'), report)
1325
+ })
1326
+
1290
1327
  // ---------------------------------------------------------------- summary
1291
1328
 
1292
1329
  const total = pass + failures.length