dsh-lite-memory 0.18.33 → 0.18.35

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/store.js CHANGED
@@ -2595,16 +2595,16 @@ export function routeScope(draft, options = {}) {
2595
2595
  requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
2596
2596
  };
2597
2597
  };
2598
- /** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
2598
+ /** 人明确点过「就存全局」才算数;模型自己提的 scope 只是提案。 */
2599
2599
  const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
2600
+ /** 实验/自动整理的产物永远留在项目层:它们没有「用户要求跨项目」这层依据。 */
2601
+ const autoOrigin = source.origin === 'project_evolution' || source.origin === 'model_drafted';
2600
2602
  /** 「本项目里…」是项目事实的措辞,类别写成偏好/流程也不能这样进通用层。 */
2601
2603
  const projectBound = projectBoundText(source);
2602
2604
  if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
2603
- if (explicit) {
2605
+ if (explicit && (humanConfirmed || !autoOrigin)) {
2604
2606
  /**
2605
- * 显式 scope 代表「用户这个消息里要的那一层」:这是唯一能让全局层落地的输入,
2606
- * 所以不能再按模型自荐降级,只有本机痕迹和项目事实两种硬冲突能改落点。
2607
- * 没显式 scope 时才用 memoryClass 提案,缺可搬依据的流程留在项目层。
2607
+ * 只有人确认过的 scope 才代表「用户这个消息里要的那一层」;模型写进参数的 scope 只是提案。
2608
2608
  */
2609
2609
  if (explicit === 'global' && !humanConfirmed) {
2610
2610
  if (machineConflict) return fallbackToProject('machine_bound_scope');
@@ -2631,21 +2631,28 @@ export function routeScope(draft, options = {}) {
2631
2631
  }
2632
2632
  return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
2633
2633
  }
2634
+ /**
2635
+ * 自动归类只能决定「它像哪类」,不能决定跨项目;默认落点一律是当前项目。
2636
+ * 具体冲突(本机痕迹、「本项目」措辞)仍按原原因报告,其余全局结论统一降级。
2637
+ */
2638
+ const globalOutcome = (reasonCode) => (humanConfirmed
2639
+ ? { ...base, chosenScope: 'global', reasonCode }
2640
+ : fallbackToProject('model_class_project'));
2634
2641
  if (cls === 'personal_preference') {
2635
2642
  /** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
2636
2643
  if (machineConflict) return fallbackToProject('machine_bound_preference');
2637
2644
  /** 说了「本项目」的偏好其实是项目约定,类别写错也不能跨项目。 */
2638
2645
  if (projectBound) return fallbackToProject('project_bound_preference');
2639
- return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
2646
+ return globalOutcome('class_personal_preference');
2640
2647
  }
2641
2648
  if (cls === 'portable_procedure') {
2642
2649
  if (machineConflict) return fallbackToProject('machine_bound_procedure');
2643
2650
  if (projectBound) return fallbackToProject('project_bound_procedure');
2644
2651
  /** 带适用环境条件的本机流程:条件成立时才用,换台机器自然不生效。 */
2645
- if (envScoped) return { ...base, chosenScope: 'global', reasonCode: 'machine_scoped_procedure' };
2652
+ if (envScoped) return globalOutcome('machine_scoped_procedure');
2646
2653
  /** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
2647
2654
  if (!evidence) return fallbackToProject('unbound_evidence');
2648
- return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
2655
+ return globalOutcome('class_portable_procedure');
2649
2656
  }
2650
2657
  if (projectBound) return fallbackToProject('project_bound_environment');
2651
2658
  const conditions = view.normalizeConditions(source.conditions) || [];
@@ -2658,7 +2665,7 @@ export function routeScope(draft, options = {}) {
2658
2665
  requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
2659
2666
  };
2660
2667
  }
2661
- return { ...base, chosenScope: 'global', reasonCode: 'class_environment_fact' };
2668
+ return globalOutcome('class_environment_fact');
2662
2669
  }
2663
2670
 
2664
2671
  function locateInLayers(projectScope, id) {
@@ -2738,7 +2745,7 @@ function bindSameTopic(projectScope, ref) {
2738
2745
  const CONFLICT_LIMIT = 20;
2739
2746
 
2740
2747
  /** 待办里留一份可重放的草稿:面板能直接另存,不用用户回忆刚才想记什么。 */
2741
- const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'topicKey', 'source', 'origin'];
2748
+ const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'selection', 'triggers', 'links', 'topicKey', 'source', 'origin'];
2742
2749
  function conflictDraftOf(draft) {
2743
2750
  const out = {};
2744
2751
  if (!draft || typeof draft !== 'object') return out;
@@ -2754,6 +2761,10 @@ function conflictDraftOf(draft) {
2754
2761
  else if (key === 'conditions') out.conditions = Array.isArray(value) ? value.slice(0, 8) : undefined;
2755
2762
  else if (key === 'applicability') out.applicability = value;
2756
2763
  else if (key === 'topicKey') out.topicKey = normalizeText(value, 80);
2764
+ /** 适用范围、召回触发词与关联整份重放:经过待裁决不能改变语义。 */
2765
+ else if (key === 'selection') out.selection = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : null;
2766
+ else if (key === 'triggers') out.triggers = Array.isArray(value) ? value.slice(0, 8).map((item) => normalizeText(item, 40)).filter(Boolean) : [];
2767
+ else if (key === 'links') out.links = Array.isArray(value) ? value.slice(0, 3).map((item) => (item && typeof item === 'object' ? { ...item } : null)).filter(Boolean) : [];
2757
2768
  else if (key === 'source') out.source = value;
2758
2769
  else if (key === 'origin') out.origin = value;
2759
2770
  }
@@ -2821,30 +2832,35 @@ function staleDraft(row, ref) {
2821
2832
 
2822
2833
  /** 确认另存:用队列里的草稿按 distinct 重放一次,成功后按 id 出队。 */
2823
2834
  export function resolveConflict(scope, ref, options = {}) {
2824
- const row = conflictRowLocked(scope, ref);
2825
- if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2826
- if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2827
- const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
2828
- if (scope.key === 'global') draft.scope = 'global';
2829
- else draft.scope = 'project';
2830
- if (!draft.title) draft.title = row.title;
2831
- const result = saveEntry(scope, draft);
2832
- if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
2833
- if (result && result.needsScope) {
2834
- const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
2835
- return { ok: false, reason, result };
2836
- }
2837
- clearConflictLocked(scope, { id: row.id });
2838
- return { ok: true, result, record: result && result.record };
2835
+ /** 版本检查、另存和出队必须在同一临界区:中间被新反馈改写时,旧面板不能批准新草稿。 */
2836
+ return withLocks([scope, globalScope()], () => {
2837
+ const row = conflictRowLocked(scope, ref);
2838
+ if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2839
+ if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2840
+ const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
2841
+ if (scope.key === 'global') draft.scope = 'global';
2842
+ else draft.scope = 'project';
2843
+ if (!draft.title) draft.title = row.title;
2844
+ const result = saveEntry(scope, draft);
2845
+ if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
2846
+ if (result && result.needsScope) {
2847
+ const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
2848
+ return { ok: false, reason, result };
2849
+ }
2850
+ clearConflictLocked(scope, { id: row.id });
2851
+ return { ok: true, result, record: result && result.record };
2852
+ });
2839
2853
  }
2840
2854
 
2841
2855
  /** 删掉待裁决:用户决定不再另存。 */
2842
2856
  export function dropConflict(scope, ref) {
2843
- const row = conflictRowLocked(scope, ref);
2844
- if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2845
- if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2846
- clearConflictLocked(scope, { id: row.id });
2847
- return { ok: true };
2857
+ return withLocks([scope, globalScope()], () => {
2858
+ const row = conflictRowLocked(scope, ref);
2859
+ if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2860
+ if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2861
+ clearConflictLocked(scope, { id: row.id });
2862
+ return { ok: true };
2863
+ });
2848
2864
  }
2849
2865
 
2850
2866
  /** 当前层还有几条没处理的冲突。 */
@@ -377,10 +377,15 @@ assert.equal((await call('/lite-memory/unknown', { cwd })).status, 404);
377
377
  assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 405);
378
378
 
379
379
  // ── global layer ─────────────────────────────────────────────────────────────
380
- const global = await exec('memory_save', { title: '回复用中文', summary: '用户要求回复用中文', scope: 'global' });
381
- assert.equal(global.scope, 'global');
382
- assert.equal(global.scopeNote, undefined, '正常落全局不该带降级说明');
383
- assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文"$/);
380
+ const proposedGlobal = await exec('memory_save', { title: '回复语言模型提案', summary: '模型建议回复语言用中文', scope: 'global' });
381
+ assert.equal(proposedGlobal.scope, 'project', '模型自填 scope 只是提案,默认落项目层');
382
+ assert.match(proposedGlobal.scopeNote, /模型归类只是提案/, '降级要能解释');
383
+ const global = store.saveEntry(store.globalScope(), {
384
+ title: '回复用中文', summary: '用户要求回复用中文', body: '回复默认用中文', scopeConfirmed: true,
385
+ });
386
+ assert.ok(global.record, '人明确要全局时才落全局层');
387
+ assert.equal(global.chosenScope, 'global');
388
+ assert.equal(exec === null, false);
384
389
 
385
390
  // 显式写 global 也不再被悄无声息地改层:本机痕迹和项目事实各有一条能解释的降级。
386
391
  const machineGlobal = await exec('memory_save', {
@@ -402,7 +407,7 @@ const factGlobal = await exec('memory_save', {
402
407
  assert.equal(factGlobal.scope, 'project');
403
408
  assert.match(factGlobal.scopeNote, /按项目事实留在项目层/);
404
409
  const globalSearch = await exec('memory_search', { query: '回复用中文' });
405
- assert.equal(globalSearch.count, 1);
410
+ assert.ok(globalSearch.count >= 1, '全局层条目能被搜到');
406
411
  assert.match(render('memory_search', {}, globalSearch), /\[global\] 「/);
407
412
  assert.equal(typeof globalThis.__liteMemoryStats, 'function');
408
413
  const stats = globalThis.__liteMemoryStats(cwd);
@@ -1050,10 +1055,9 @@ await defs.get('memory_save').execute(
1050
1055
  { title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', when: '周四晚上' },
1051
1056
  { agent: ovAgent },
1052
1057
  );
1053
- await defs.get('memory_save').execute(
1054
- { title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scope: 'global' },
1055
- { agent: ovAgent },
1056
- );
1058
+ store.saveEntry(store.globalScope(), {
1059
+ title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scopeConfirmed: true,
1060
+ });
1057
1061
  const ovOther = await defs.get('memory_save').execute(
1058
1062
  { title: '别的项目', summary: '只在别的项目里', body: '另一层的正文。', tags: ['别的'] },
1059
1063
  { agent: { id: 'session-overview-other', cwd: ovOtherCwd, session: { id: 'session-overview-other', header: { cwd: ovOtherCwd } } } },
@@ -1215,7 +1219,7 @@ const cfCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-confli
1215
1219
  sessionCwds.set('session-conflict-panel', cfCwd);
1216
1220
  sessionCwds.set('session-conflict-other', fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-other-')));
1217
1221
  const cfAgent = { id: 'session-conflict-panel', cwd: cfCwd, session: { id: 'session-conflict-panel', header: { cwd: cfCwd } } };
1218
- await defs.get('memory_save').execute({ title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scope: 'global' }, { agent: cfAgent });
1222
+ store.saveEntry(store.globalScope(), { title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scopeConfirmed: true });
1219
1223
  const cfSave = await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目周五发版', body: '周五发版。' }, { agent: cfAgent });
1220
1224
  assert.equal(cfSave.needsResolution, true);
1221
1225
  assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '没确认前不落库');
@@ -1276,7 +1280,7 @@ const cfgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-confl
1276
1280
  sessionCwds.set('session-conflict-global', cfgCwd);
1277
1281
  const cfgAgent = { id: 'session-conflict-global', cwd: cfgCwd, session: { id: 'session-conflict-global', header: { cwd: cfgCwd } } };
1278
1282
  await defs.get('memory_save').execute({ title: '命名规则', summary: '项目里用短横线', body: '短横线。' }, { agent: cfgAgent });
1279
- const cfgSave = await defs.get('memory_save').execute({ title: '命名规则', summary: '所有项目用下划线', body: '下划线。', scope: 'global' }, { agent: cfgAgent });
1283
+ const cfgSave = store.saveEntry(store.scopeOf(cfgCwd), { title: '命名规则', summary: '所有项目用下划线', body: '下划线。', scope: 'global', scopeConfirmed: true });
1280
1284
  assert.equal(cfgSave.needsResolution, true);
1281
1285
  const cfgView = await call('/lite-memory/overview', { sessionId: 'session-conflict-global', scopeKey: 'global' });
1282
1286
  assert.equal(cfgView.body.selected.conflicts.length, 1, '全局层也要有这条待裁决');
@@ -44,12 +44,18 @@ const idsOfPrev = (scope) => {
44
44
  };
45
45
 
46
46
  // ── S03 范围路由 ────────────────────────────────────────────────────────────
47
- await test('S03 个人偏好没有显式范围就进全局层,项目层不落副本', () => {
47
+ await test('S03 个人偏好:模型自动归类留项目层,人明确要全局才落全局', () => {
48
48
  const scope = fresh('route-pref');
49
- const saved = saveEntry(scope, { title: '回答用中文', summary: '回复语言偏好', body: '中文、简短', memoryClass: 'personal_preference' });
49
+ const automatic = saveEntry(scope, { title: '回答用中文(自动)', summary: '回复语言偏好', body: '中文、简短(自动)', memoryClass: 'personal_preference' });
50
+ assert.equal(automatic.chosenScope, 'project', '模型归类只是提案,不能自己跨项目');
51
+ assert.equal(automatic.reasonCode, 'model_class_project');
52
+ assert.equal(idsOf(scope).length, 1);
53
+ const saved = saveEntry(scope, {
54
+ title: '回答用中文', summary: '回复语言偏好', body: '中文、简短',
55
+ memoryClass: 'personal_preference', scopeConfirmed: true, resolution: 'distinct',
56
+ });
50
57
  assert.equal(saved.chosenScope, 'global');
51
58
  assert.equal(saved.reasonCode, 'class_personal_preference');
52
- assert.equal(idsOf(scope).length, 0);
53
59
  assert.equal(stateOf(globalScope()).entries.filter((row) => row.current.title === '回答用中文').length, 1);
54
60
  });
55
61
 
@@ -100,7 +106,7 @@ await test('S03 设备事实没给设备条件就返回 needsScope,不落盘',
100
106
  await test('S03 设备事实带条件进全局层且是条件适用', () => {
101
107
  const scope = fresh('route-env-ok');
102
108
  const saved = saveEntry(scope, {
103
- title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact',
109
+ title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact', scopeConfirmed: true,
104
110
  conditions: [{ key: 'machine', value: 'mac-mini' }],
105
111
  });
106
112
  assert.equal(saved.chosenScope, 'global');
@@ -434,7 +440,32 @@ await test('S12 待裁决留可重放草稿,面板确认另存后才落库', (
434
440
  assert.equal(pendingConflictsOf(scope), 0);
435
441
  assert.equal(loadIndex(scope).entries.length, 1);
436
442
  assert.equal(loadIndex(scope).entries[0].body, '项目补充');
443
+ assert.equal(loadIndex(scope).entries[0].when, '本仓库', 'when 经过待裁决不能丢');
437
444
  assert.equal(dropConflict(scope, '不存在').ok, false);
438
445
  });
439
446
 
447
+ await test('S12 经过待裁决另存不改变适用范围、触发词和关联', () => {
448
+ const scope = fresh('conflict-semantics');
449
+ const globalOne = saveEntry(globalScope(), { title: '发票抬头规则', summary: '开票信息固定', body: '全局版本', resolution: 'distinct' });
450
+ const linked = saveEntry(globalScope(), { title: '生效核验顺序', summary: '先验规则再验页面', body: '先验规则', resolution: 'distinct' });
451
+ const near = saveEntry(scope, {
452
+ title: '发票抬头规则', summary: '本项目发票只认这套抬头', body: '项目版本',
453
+ selection: { mode: 'complete', artifactKey: 'artifact-invoice-v1' },
454
+ triggers: ['artifact-hookword'],
455
+ links: [{ type: 'related', id: linked.record.id }],
456
+ topicKey: 'invoice',
457
+ });
458
+ assert.equal(near.needsResolution, true);
459
+ const [row] = conflictsOf(scope);
460
+ assert.deepEqual(row.draft.selection, { mode: 'complete', artifactKey: 'artifact-invoice-v1', items: [] });
461
+ assert.deepEqual(row.draft.triggers, ['artifact-hookword']);
462
+ assert.deepEqual(row.draft.links, [{ type: 'related', id: linked.record.id }]);
463
+ const kept = resolveConflict(scope, { id: row.id, expectedRevision: row.revision });
464
+ assert.equal(kept.ok, true, JSON.stringify(kept));
465
+ const [record] = loadIndex(scope).entries;
466
+ assert.deepEqual(record.selection, { mode: 'complete', artifactKey: 'artifact-invoice-v1', items: [] });
467
+ assert.deepEqual(record.triggers, ['artifact-hookword']);
468
+ assert.deepEqual(record.links, [{ type: 'related', id: linked.record.id }]);
469
+ });
470
+
440
471
  console.log(`crossing.test.mjs: ${passed} passed`);
@@ -1,4 +1,4 @@
1
- import { fileURLToPath } from 'node:url'
1
+ import { fileURLToPath } from 'node:url'
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
@@ -172,7 +172,7 @@ const globalDraft = store.saveEntry(store.scopeOf(projectB), {
172
172
  title: 'npm 发布凭据取用与发布后验版',
173
173
  summary: 'npm 发布用 account regrevia,发布后轮询 dist-tags 验版',
174
174
  body: '发布用 regrevia 身份;发完轮询 dist-tags.latest 判上线。',
175
- memoryClass: 'personal_preference',
175
+ memoryClass: 'personal_preference', scope: 'global', scopeConfirmed: true,
176
176
  });
177
177
  assert.equal(globalDraft.chosenScope, 'global');
178
178
  const near = await exec('memory_save', {
@@ -1036,6 +1036,54 @@ test('L03d2 代码任务学到的规则不会在数据库任务里生效', () =>
1036
1036
  assert.equal(db.reason, 'condition_mismatch');
1037
1037
  });
1038
1038
 
1039
+ test('L03d3 空项目的第一条反馈能带上真实任务范围保存', () => {
1040
+ const parsed = learning.parseOutput(JSON.stringify({
1041
+ changes: [{
1042
+ target: 'new', feedbackType: 'spec_change', topicKey: '前端边框',
1043
+ currentText: '前端页面默认采用细边框', applicability: 'all_in_scope', evidence: ['u0'],
1044
+ }],
1045
+ }), {
1046
+ finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1047
+ ranges: { taskKinds: [], series: [], projects: ['p'] },
1048
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
1049
+ });
1050
+ assert.equal(parsed.ok, true, parsed.reason);
1051
+ assert.equal(parsed.changes[0].applicability, 'conditional');
1052
+ assert.deepEqual(parsed.changes[0].conditions, [{ key: 'taskKind', value: 'code' }], '采集阶段不该锁死长期适用范围');
1053
+ });
1054
+
1055
+ test('L03d4 模型条件与采集任务范围冲突时拒绝,不生成永不生效的规则', () => {
1056
+ const parsed = learning.parseOutput(JSON.stringify({
1057
+ changes: [{
1058
+ target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
1059
+ currentText: '先跑迁移再改代码', applicability: 'conditional',
1060
+ conditions: [{ key: 'taskKind', value: 'database' }], evidence: ['u0'],
1061
+ }],
1062
+ }), {
1063
+ finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1064
+ ranges: { ...RANGES, taskKinds: ['code', 'database'] },
1065
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code' },
1066
+ });
1067
+ assert.equal(parsed.ok, false);
1068
+ assert.match(parsed.reason, /冲突/);
1069
+ });
1070
+
1071
+ test('L03d5 同一条件键的两个值不能都留下', () => {
1072
+ const parsed = learning.parseOutput(JSON.stringify({
1073
+ changes: [{
1074
+ target: 'new', feedbackType: 'spec_change', topicKey: '阶段冲突',
1075
+ currentText: '只在脚本阶段生效', applicability: 'conditional',
1076
+ conditions: [{ key: 'phase', value: 'script' }, { key: 'phase', value: 'brief' }], evidence: ['u0'],
1077
+ }],
1078
+ }), {
1079
+ finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1080
+ ranges: { ...RANGES },
1081
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
1082
+ });
1083
+ assert.equal(parsed.ok, false);
1084
+ assert.match(parsed.reason, /条件/);
1085
+ });
1086
+
1039
1087
  test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
1040
1088
  const rules = [];
1041
1089
  for (let index = 0; index < 32; index += 1) {
@@ -1360,4 +1408,43 @@ test('L10 整理器规则先稳定排序再取 8', () => {
1360
1408
  assert.equal(learning.orderOrganizerRules([], { limit: 3 }).length, 0);
1361
1409
  });
1362
1410
 
1411
+ /* 收敛:连续纠正同一主题只留一条现行规则;换栏目不适用;关掉再开还是同一现行版。 */
1412
+ test('B30 连续纠正同一规则收敛到一条现行版', () => {
1413
+ const scope = fresh('converge');
1414
+ enable(scope);
1415
+ const firstEpisode = seedEpisode(scope, { sessionId: 'conv1', messageIds: ['cm1'] });
1416
+ const firstStart = begin(scope, firstEpisode);
1417
+ assert.equal(commitNew(scope, firstEpisode, firstStart, NEW_CHANGE).ok, true);
1418
+ const id = ruleRows(scope)[0].id;
1419
+ const bodies = ['开头两句内给结论。', '开头一句内给结论。'];
1420
+ bodies.forEach((body, index) => {
1421
+ const episode = seedEpisode(scope, { sessionId: `conv${index + 2}`, messageIds: [`cm${index + 2}`] });
1422
+ const started = begin(scope, episode);
1423
+ assert.equal(started.ok, true, started.reason);
1424
+ const result = commitExisting(scope, episode, started, id, {
1425
+ target: 'r0', feedbackType: 'spec_change', currentText: body, evidence: ['u0'],
1426
+ });
1427
+ assert.equal(result.ok, true, result.reason);
1428
+ });
1429
+ const rows = ruleRows(scope);
1430
+ assert.equal(rows.length, 1, '同一主题不堆多份现行规则');
1431
+ const current = rows[0];
1432
+ assert.equal(current.id, id);
1433
+ assert.match(current.body, /一句内给结论/);
1434
+ assert.ok((current.versions || []).length <= 2, '旧版有上限');
1435
+ const wrongColumn = view.eligibility(current, {
1436
+ projectId: scope.key,
1437
+ context: { seriesId: '字幕' },
1438
+ task: { taskKind: 'video', seriesId: '字幕' },
1439
+ }, 'auto');
1440
+ assert.equal(wrongColumn.ok, false, '换栏目不适用');
1441
+ store.setMemoryEnabled(scope, false, { operationId: 'conv-close' });
1442
+ assert.equal(ruleRows(scope).length, 1, '关闭不清成果');
1443
+ store.setMemoryEnabled(scope, true, { operationId: 'conv-open' });
1444
+ const reopened = ruleOf(scope, id);
1445
+ assert.equal(reopened.body, current.body);
1446
+ assert.equal(reopened.revision, current.revision);
1447
+ assert.equal(reopened.learning.state, current.learning.state);
1448
+ });
1449
+
1363
1450
  console.log(`learning.test.mjs: ${passed} passed`);