dsh-lite-memory 0.18.28 → 0.18.30

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/client.js CHANGED
@@ -2157,6 +2157,7 @@ window.__ModuleLoader__.load({
2157
2157
  const [display, setDisplay] = React.useState({ ...DISPLAY_DEFAULTS });
2158
2158
  const [hubFilter, setHubFilter] = React.useState(null);
2159
2159
  const [checkup, setCheckup] = React.useState(false);
2160
+ const [expandedConflict, setExpandedConflict] = React.useState('');
2160
2161
  const alive = React.useRef(true);
2161
2162
  const generation = React.useRef(0);
2162
2163
  const scopeRef = React.useRef('');
@@ -2200,11 +2201,30 @@ window.__ModuleLoader__.load({
2200
2201
 
2201
2202
  React.useEffect(() => { load(); }, [sessionId]);
2202
2203
 
2204
+ /** 待裁决只做两件事:把草稿另存成一条,或删掉这条待办;不在面板里悄悄合并。 */
2205
+ const resolveQueued = (row, op) => {
2206
+ if (!row || busy) return;
2207
+ setBusy(true);
2208
+ setNote('');
2209
+ post('/lite-memory/conflict', {
2210
+ sessionId, scopeKey: selectedKey, conflictId: row.id, expectedRevision: row.revision, op,
2211
+ }).then((result) => {
2212
+ if (!alive.current) return;
2213
+ setBusy(false);
2214
+ if (result.ok && result.body && result.body.ok) {
2215
+ setExpandedConflict('');
2216
+ if (op === 'drop') setNote('已忽略这条待裁决');
2217
+ load(selectedKey);
2218
+ } else setNote(failure(result, '待裁决没处理成'));
2219
+ });
2220
+ };
2221
+
2203
2222
  /** 显示设置跟着当前项目走;换项目或换范围时重新读。 */
2204
2223
  React.useEffect(() => {
2205
2224
  setDisplay(readDisplaySettings(selectedKey));
2206
2225
  setShowSettings(false);
2207
2226
  setHubFilter(null);
2227
+ setExpandedConflict('');
2208
2228
  }, [selectedKey]);
2209
2229
  React.useEffect(() => {
2210
2230
  setMemoryView((value) => viewModeOf(display, value));
@@ -2249,6 +2269,7 @@ window.__ModuleLoader__.load({
2249
2269
  const selected = data ? data.selected : null;
2250
2270
  const records = selected && Array.isArray(selected.records) ? selected.records : EMPTY;
2251
2271
  const total = selected && selected.stats && Number.isInteger(selected.stats.total) ? selected.stats.total : records.length;
2272
+ const queued = selected && Array.isArray(selected.conflicts) ? selected.conflicts : EMPTY;
2252
2273
  const currentKey = current ? current.key : '';
2253
2274
  const control = selected && selected.control
2254
2275
  ? selected.control
@@ -2710,12 +2731,47 @@ window.__ModuleLoader__.load({
2710
2731
  const memoryViewActive = viewModeOf(display, memoryView);
2711
2732
  const evolutionViewActive = graphViewOf(display.graph, evolutionView);
2712
2733
 
2734
+ const conflictReadOnly = !memoryOn || (!globalView && selectedKey !== currentKey);
2735
+ const draftScope = (row) => (row.scope === 'global' ? '总记忆' : '本项目');
2736
+ const conflictBar = queued.length === 0 ? null : h('div', {
2737
+ style: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '7px 9px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' },
2738
+ },
2739
+ h('div', { style: { ...PANEL_MUTED, lineHeight: '18px' } }, `${queued.length} 条保存没确认${conflictReadOnly ? ' · 只读' : ''}`),
2740
+ queued.slice(0, 2).map((row) => {
2741
+ const open = expandedConflict === row.id;
2742
+ const draft = row.draft || {};
2743
+ return h('div', {
2744
+ key: row.id || row.title,
2745
+ style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 },
2746
+ },
2747
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 } },
2748
+ h('button', {
2749
+ type: 'button',
2750
+ 'aria-expanded': open,
2751
+ onClick: () => setExpandedConflict(open ? '' : row.id),
2752
+ style: {
2753
+ flex: 1, minWidth: 0, padding: 0, border: 0, background: 'transparent', color: 'inherit',
2754
+ textAlign: 'left', font: 'inherit', fontSize: '12px', lineHeight: '20px',
2755
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
2756
+ },
2757
+ }, row.title),
2758
+ conflictReadOnly ? null : h(Action, { disabled: busy, onClick: () => resolveQueued(row, 'keep') }, '另存'),
2759
+ conflictReadOnly ? null : h(Action, { disabled: busy, onClick: () => resolveQueued(row, 'drop') }, '忽略'),
2760
+ ),
2761
+ open ? h('div', {
2762
+ style: { ...PANEL_MUTED, padding: '2px 0 4px', lineHeight: '18px', overflowWrap: 'anywhere' },
2763
+ },
2764
+ h('div', null, `范围:${draftScope(row)}`),
2765
+ draft.when ? h('div', null, `适用:${draft.when}`) : null,
2766
+ h('div', null, String(draft.body || draft.summary || '(没有正文)').slice(0, 400)),
2767
+ ) : null);
2768
+ }));
2713
2769
  const memoryListPane = h('div', {
2714
2770
  style: {
2715
2771
  ...PANEL_CARD, padding: '2px 4px', minHeight: 0, overflowY: 'auto',
2716
2772
  display: 'flex', flexDirection: 'column', gap: 0,
2717
2773
  },
2718
- }, memoryList);
2774
+ }, conflictBar, memoryList);
2719
2775
  const memoryGraphPane = h('div', {
2720
2776
  style: { ...PANEL_CARD, padding: 0, minHeight: 0, overflow: 'hidden', display: 'flex' },
2721
2777
  },
@@ -0,0 +1,7 @@
1
+ # 0.18.30
2
+
3
+ - 待裁决入口按「当前会话项目 + 目标层 + 草稿版本」核验:跨项目请求直接拒绝,不再把别的 scope 静默解释成当前项目。
4
+ - 项目记忆关闭时,本入口(含选全局层另存)一律拒绝,不绕过已有总开关。
5
+ - 队列行增加稳定 id 与 revision;草稿被后续反馈替换后,旧面板的「另存」返回 `stale_draft`,不会保存用户没看过的新正文。
6
+ - 面板可展开一条待裁决,看草稿正文、适用范围和目标层;关闭或跨项目浏览时显示只读,不出现操作按钮。
7
+ - 回归覆盖:跨项目拒绝、关闭态拒绝、旧版本拒绝、全局草稿在项目关闭时拒绝。
package/index.js CHANGED
@@ -13,6 +13,9 @@ import {
13
13
  runtimeLayers,
14
14
  findAnywhere,
15
15
  renderIndex,
16
+ conflictsOf,
17
+ resolveConflict,
18
+ dropConflict,
16
19
  loadIndex,
17
20
  saveEntry,
18
21
  move,
@@ -214,6 +217,7 @@ function keepHandler(webServer, cwdForSession) {
214
217
  : pathname === REMOTE_PATH + '/entry' ? 'entry'
215
218
  : pathname === REMOTE_PATH + '/move' ? 'move'
216
219
  : pathname === REMOTE_PATH + '/overview' ? 'overview'
220
+ : pathname === REMOTE_PATH + '/conflict' ? 'conflict'
217
221
  : pathname === REMOTE_PATH + '/control' ? 'control'
218
222
  : pathname === REMOTE_PATH + '/evolution' ? 'evolution'
219
223
  : pathname === REMOTE_PATH + '/resume-move' ? 'resume-move' : '';
@@ -530,6 +534,7 @@ function keepHandler(webServer, cwdForSession) {
530
534
  evolution: selectedEvolution,
531
535
  records,
532
536
  archived,
537
+ conflicts: conflictsOf(selectedScope),
533
538
  stats: {
534
539
  total: records.length,
535
540
  experimental: records.filter((record) => record.experimental).length,
@@ -541,6 +546,54 @@ function keepHandler(webServer, cwdForSession) {
541
546
  });
542
547
  }
543
548
 
549
+ /** 待裁决:用队列里留下的草稿另存,或删掉这条待办;不预写、不替用户合并。 */
550
+ if (action === 'conflict') {
551
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
552
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
553
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
554
+ if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
555
+ const projectScope = scopeOf(cwd);
556
+ if (!projectScope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
557
+ /** 请求里的目标必须与当前会话项目一致;别的项目只读,不能借面板改过去。 */
558
+ const requested = typeof body.scopeKey === 'string' ? body.scopeKey : '';
559
+ if (!requested) return sendJson(res, 400, { ok: false, reason: '缺少目标范围' });
560
+ if (requested !== 'global' && requested !== projectScope.key) {
561
+ return sendJson(res, 409, { ok: false, reason: '别的项目只读,回到那个项目里再改' });
562
+ }
563
+ const target = requested === 'global' ? globalScope() : projectScope;
564
+ /** 关闭的项目记忆不能从这个入口绕过;选全局时全局总开关也必须开着。 */
565
+ if (!memoryEnabled(projectScope)) {
566
+ return sendJson(res, 409, { ok: false, code: 'memory_disabled', reason: memoryOffCopy(projectScope) });
567
+ }
568
+ if (target.key === 'global' && !memoryEnabled(target)) {
569
+ return sendJson(res, 409, { ok: false, code: 'memory_disabled', reason: '总记忆已关闭,先在面板打开' });
570
+ }
571
+ const op = body.op;
572
+ if (op !== 'keep' && op !== 'drop') return sendJson(res, 400, { ok: false, reason: '不认识这个操作' });
573
+ const conflictId = typeof body.conflictId === 'string' ? body.conflictId.trim() : '';
574
+ if (!conflictId) return sendJson(res, 400, { ok: false, reason: '缺少这条待裁决' });
575
+ const expectedRevision = Number(body.expectedRevision);
576
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
577
+ return sendJson(res, 409, { ok: false, reason: '这条待裁决的版本不清楚,先刷新再处理' });
578
+ }
579
+ try {
580
+ const ref = { id: conflictId, expectedRevision };
581
+ const outcome = op === 'drop' ? dropConflict(target, ref) : resolveConflict(target, ref);
582
+ if (!outcome.ok) {
583
+ return sendJson(res, outcome.reasonCode === 'stale_draft' ? 409 : 409, {
584
+ ok: false, code: outcome.reasonCode || '', reason: outcome.reason || '这条待裁决还没处理成',
585
+ });
586
+ }
587
+ return sendJson(res, 200, {
588
+ ok: true,
589
+ remaining: conflictsOf(target).length,
590
+ record: outcome.record ? { id: outcome.record.id, title: outcome.record.title, revision: outcome.record.revision } : null,
591
+ });
592
+ } catch (error) {
593
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '待裁决处理失败' });
594
+ }
595
+ }
596
+
544
597
  /**
545
598
  * 本项目记忆总开关:关闭后这层不再召回、检索、读写、学习,也不继承全局记忆。
546
599
  * 自进化设置原样保留,但有效状态随总开关暂停。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.28",
3
+ "version": "0.18.30",
4
4
  "description": "On-demand memory for the DeepSeek Harness: nothing resident, at most three small cards on a real hit, bodies read on demand.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/store.js CHANGED
@@ -2723,6 +2723,29 @@ function bindSameTopic(projectScope, ref) {
2723
2723
  /** 冲突队列:只记待办,不预写记忆;最多 20 条。 */
2724
2724
  const CONFLICT_LIMIT = 20;
2725
2725
 
2726
+ /** 待办里留一份可重放的草稿:面板能直接另存,不用用户回忆刚才想记什么。 */
2727
+ const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'topicKey', 'source', 'origin'];
2728
+ function conflictDraftOf(draft) {
2729
+ const out = {};
2730
+ if (!draft || typeof draft !== 'object') return out;
2731
+ for (const key of CONFLICT_DRAFT_KEYS) {
2732
+ const value = draft[key];
2733
+ if (value === undefined || value === null) continue;
2734
+ if (key === 'title') out.title = normalizeText(value, 120);
2735
+ else if (key === 'summary') out.summary = normalizeText(value, 400);
2736
+ else if (key === 'body') out.body = typeof value === 'string' ? value.slice(0, 8000) : '';
2737
+ else if (key === 'when') out.when = normalizeText(value, 160);
2738
+ else if (key === 'kind') { if (KINDS.includes(value)) out.kind = value; }
2739
+ else if (key === 'tags') out.tags = Array.isArray(value) ? value.slice(0, 5) : undefined;
2740
+ else if (key === 'conditions') out.conditions = Array.isArray(value) ? value.slice(0, 8) : undefined;
2741
+ else if (key === 'applicability') out.applicability = value;
2742
+ else if (key === 'topicKey') out.topicKey = normalizeText(value, 80);
2743
+ else if (key === 'source') out.source = value;
2744
+ else if (key === 'origin') out.origin = value;
2745
+ }
2746
+ return out;
2747
+ }
2748
+
2726
2749
  function queueConflictLocked(scope, draft, candidates) {
2727
2750
  const index = loadIndex(scope);
2728
2751
  const title = normalizeText(draft && draft.title, 120);
@@ -2734,28 +2757,82 @@ function queueConflictLocked(scope, draft, candidates) {
2734
2757
  id: item.id, title: item.title || '', when: item.when || '', reason: item.reason || 'similar_title',
2735
2758
  }));
2736
2759
  const hit = rows.find((row) => row.title === title && row.scope === scopeKey);
2760
+ const replay = conflictDraftOf(draft);
2737
2761
  if (hit) {
2738
2762
  hit.candidates = view;
2763
+ hit.draft = replay;
2764
+ /** 草稿被后来的反馈替换过:版本必须涨,旧面板不能拿旧版直接批准。 */
2765
+ hit.revision = (Number(hit.revision) || 1) + 1;
2739
2766
  hit.updatedAt = now;
2740
2767
  } else {
2741
- rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, createdAt: now, updatedAt: now });
2768
+ rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, draft: replay, revision: 1, createdAt: now, updatedAt: now });
2742
2769
  }
2743
2770
  index.conflicts = rows.slice(-CONFLICT_LIMIT);
2744
2771
  saveIndex(scope, index);
2745
2772
  return index.conflicts.length;
2746
2773
  }
2747
2774
 
2748
- function clearConflictLocked(scope, title) {
2775
+ /** 按稳定 id 找;旧调用还传标题,只为兼容已有数据与测试。 */
2776
+ function conflictRowLocked(scope, ref) {
2777
+ const rows = Array.isArray(loadIndex(scope).conflicts) ? loadIndex(scope).conflicts : [];
2778
+ const layer = scope.key === 'global' ? 'global' : 'project';
2779
+ if (ref && typeof ref === 'object' && ref.id) return rows.find((row) => row.id === ref.id && row.scope === layer) || null;
2780
+ const norm = normalizeText(typeof ref === 'string' ? ref : '', 120);
2781
+ return rows.find((row) => row.title === norm && row.scope === layer) || null;
2782
+ }
2783
+
2784
+ function clearConflictLocked(scope, ref) {
2749
2785
  const index = loadIndex(scope);
2750
2786
  const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
2751
- const norm = normalizeText(title, 120);
2752
- const next = rows.filter((row) => !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project')));
2787
+ const id = ref && typeof ref === 'object' && ref.id ? ref.id : '';
2788
+ const norm = id ? '' : normalizeText(typeof ref === 'string' ? ref : '', 120);
2789
+ const next = rows.filter((row) => (id ? row.id !== id : !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project'))));
2753
2790
  if (next.length === rows.length) return rows.length;
2754
2791
  index.conflicts = next;
2755
2792
  saveIndex(scope, index);
2756
2793
  return next.length;
2757
2794
  }
2758
2795
 
2796
+ /** 面板待裁决列表:只给可重放草稿与候选,不预写记忆。 */
2797
+ export function conflictsOf(scope) {
2798
+ const rows = loadIndex(scope).conflicts;
2799
+ return Array.isArray(rows) ? JSON.parse(JSON.stringify(rows)) : [];
2800
+ }
2801
+
2802
+ /** 版本对不上就拒绝:用户看到的草稿和现在这条不是同一版。 */
2803
+ function staleDraft(row, ref) {
2804
+ if (!ref || typeof ref !== 'object' || ref.expectedRevision === undefined) return false;
2805
+ return Number(ref.expectedRevision) !== (Number(row.revision) || 1);
2806
+ }
2807
+
2808
+ /** 确认另存:用队列里的草稿按 distinct 重放一次,成功后按 id 出队。 */
2809
+ export function resolveConflict(scope, ref, options = {}) {
2810
+ const row = conflictRowLocked(scope, ref);
2811
+ if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2812
+ if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2813
+ const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
2814
+ if (scope.key === 'global') draft.scope = 'global';
2815
+ else draft.scope = 'project';
2816
+ if (!draft.title) draft.title = row.title;
2817
+ const result = saveEntry(scope, draft);
2818
+ if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
2819
+ if (result && result.needsScope) {
2820
+ const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
2821
+ return { ok: false, reason, result };
2822
+ }
2823
+ clearConflictLocked(scope, { id: row.id });
2824
+ return { ok: true, result, record: result && result.record };
2825
+ }
2826
+
2827
+ /** 删掉待裁决:用户决定不再另存。 */
2828
+ export function dropConflict(scope, ref) {
2829
+ const row = conflictRowLocked(scope, ref);
2830
+ if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2831
+ if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2832
+ clearConflictLocked(scope, { id: row.id });
2833
+ return { ok: true };
2834
+ }
2835
+
2759
2836
  /** 当前层还有几条没处理的冲突。 */
2760
2837
  export function pendingConflictsOf(scope) {
2761
2838
  const rows = loadIndex(scope).conflicts;
@@ -1210,4 +1210,89 @@ assert.equal(ovArchivedBody.status, 200);
1210
1210
  assert.equal(ovArchivedBody.body.status, 'archived');
1211
1211
  assert.equal(ovArchivedBody.body.body, '右侧留白。');
1212
1212
 
1213
+ // 待裁决:面板看得到队列,确认后按草稿另存,不预写正文
1214
+ const cfCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-'));
1215
+ sessionCwds.set('session-conflict-panel', cfCwd);
1216
+ sessionCwds.set('session-conflict-other', fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-other-')));
1217
+ 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 });
1219
+ const cfSave = await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目周五发版', body: '周五发版。' }, { agent: cfAgent });
1220
+ assert.equal(cfSave.needsResolution, true);
1221
+ assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '没确认前不落库');
1222
+ const cfBefore = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
1223
+ assert.equal(cfBefore.status, 200);
1224
+ assert.equal(cfBefore.body.selected.conflicts.length, 1);
1225
+ const cfRow = cfBefore.body.selected.conflicts[0];
1226
+ assert.equal(cfRow.title, '发布窗口');
1227
+ assert.equal(cfRow.draft.body, '周五发版。');
1228
+ assert.ok(cfRow.id, '待裁决要有稳定 id');
1229
+ assert.equal(cfRow.revision, 1);
1230
+ const cfScopeKey = cfBefore.body.selected.key;
1231
+
1232
+ // 跨项目:当前会话是 A,请求 B 的范围必须拒绝,且不能动到 A 的草稿
1233
+ const cfCross = await call('/lite-memory/conflict', {
1234
+ sessionId: 'session-conflict-panel', scopeKey: '另一个项目', conflictId: cfRow.id, expectedRevision: 1, op: 'drop',
1235
+ });
1236
+ assert.equal(cfCross.status, 409);
1237
+ assert.match(cfCross.body.reason, /只读/);
1238
+ assert.equal(store.loadIndex(store.scopeOf(cfCwd)).conflicts.length, 1, '跨项目请求不改数据');
1239
+
1240
+ // 关闭项目记忆后,本入口也不能落盘
1241
+ await call('/lite-memory/control', { sessionId: 'session-conflict-panel', enabled: false, operationId: 'op_cf_close' });
1242
+ const cfClosed = await call('/lite-memory/conflict', {
1243
+ sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 1, op: 'keep',
1244
+ });
1245
+ assert.equal(cfClosed.status, 409);
1246
+ assert.equal(cfClosed.body.code, 'memory_disabled');
1247
+ assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '关闭状态不写记忆');
1248
+ await call('/lite-memory/control', { sessionId: 'session-conflict-panel', enabled: true, operationId: 'op_cf_reopen' });
1249
+
1250
+ // 草稿被后来的反馈替换:旧版本点击必须拒绝
1251
+ await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目改成周六发版', body: '周六发版。' }, { agent: cfAgent });
1252
+ const cfStale = await call('/lite-memory/conflict', {
1253
+ sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 1, op: 'keep',
1254
+ });
1255
+ assert.equal(cfStale.status, 409);
1256
+ assert.equal(cfStale.body.code, 'stale_draft');
1257
+ const cfStaleView = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
1258
+ assert.equal(cfStaleView.body.selected.conflicts[0].revision, 2, '草稿换了版本要涨');
1259
+ assert.equal(cfStaleView.body.selected.conflicts[0].draft.body, '周六发版。');
1260
+
1261
+ const cfKeep = await call('/lite-memory/conflict', {
1262
+ sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 2, op: 'keep',
1263
+ });
1264
+ assert.equal(cfKeep.status, 200);
1265
+ assert.equal(cfKeep.body.ok, true);
1266
+ const cfAfter = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
1267
+ assert.equal(cfAfter.body.selected.conflicts.length, 0);
1268
+ assert.ok(cfAfter.body.selected.records.some((row) => row.title === '发布窗口' && row.summary === '本项目改成周六发版'));
1269
+ const cfAgain = await call('/lite-memory/conflict', {
1270
+ sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 2, op: 'keep',
1271
+ });
1272
+ assert.equal(cfAgain.status, 409, '队列没了就明确说,不重复落库');
1273
+
1274
+ // 项目记忆关闭时,选全局待裁决也不能借这个入口落盘
1275
+ const cfgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-global-'));
1276
+ sessionCwds.set('session-conflict-global', cfgCwd);
1277
+ const cfgAgent = { id: 'session-conflict-global', cwd: cfgCwd, session: { id: 'session-conflict-global', header: { cwd: cfgCwd } } };
1278
+ 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 });
1280
+ assert.equal(cfgSave.needsResolution, true);
1281
+ const cfgView = await call('/lite-memory/overview', { sessionId: 'session-conflict-global', scopeKey: 'global' });
1282
+ assert.equal(cfgView.body.selected.conflicts.length, 1, '全局层也要有这条待裁决');
1283
+ const cfgRow = cfgView.body.selected.conflicts[0];
1284
+ await call('/lite-memory/control', { sessionId: 'session-conflict-global', enabled: false, operationId: 'op_cfg_close' });
1285
+ const cfgClosed = await call('/lite-memory/conflict', {
1286
+ sessionId: 'session-conflict-global', scopeKey: 'global', conflictId: cfgRow.id, expectedRevision: cfgRow.revision, op: 'keep',
1287
+ });
1288
+ assert.equal(cfgClosed.status, 409);
1289
+ assert.equal(cfgClosed.body.code, 'memory_disabled');
1290
+ assert.equal(store.conflictsOf(store.globalScope()).length, 1, '关闭状态不批准全局草稿');
1291
+ await call('/lite-memory/control', { sessionId: 'session-conflict-global', enabled: true, operationId: 'op_cfg_reopen' });
1292
+ const cfgKeep = await call('/lite-memory/conflict', {
1293
+ sessionId: 'session-conflict-global', scopeKey: 'global', conflictId: cfgRow.id, expectedRevision: cfgRow.revision, op: 'keep',
1294
+ });
1295
+ assert.equal(cfgKeep.status, 200);
1296
+ assert.equal(store.conflictsOf(store.globalScope()).length, 0);
1297
+
1213
1298
  console.log('contract.test.mjs: all passed');
@@ -11,6 +11,7 @@ const view = await import('../view.js');
11
11
  const {
12
12
  saveEntry, routeScope, visibleRules, move, resumeMove, movePending,
13
13
  upsert, forget, readLinks, loadIndex, scopeOf, globalScope, readEntry, pendingConflictsOf,
14
+ conflictsOf, resolveConflict, dropConflict,
14
15
  } = store;
15
16
 
16
17
  let passed = 0;
@@ -79,6 +80,12 @@ await test('S03 模型给的类别标签不能把「本项目」抬进全局层'
79
80
  });
80
81
  assert.equal(proc.chosenScope, 'project');
81
82
  assert.equal(proc.reasonCode, 'project_bound_procedure');
83
+ /** 人自己确认「就存全局」时,措辞推断不能压过明确指令。 */
84
+ const human = routeScope({
85
+ title: '本项目回复用中文', body: '本项目统一中文、简短', memoryClass: 'personal_preference', scope: 'global',
86
+ }, { humanScope: true, project: { confirmed: true } });
87
+ assert.equal(human.chosenScope, 'global');
88
+ assert.equal(human.reasonCode, 'explicit');
82
89
  });
83
90
 
84
91
  await test('S03 设备事实没给设备条件就返回 needsScope,不落盘', () => {
@@ -412,4 +419,22 @@ await test('S12 相近保存进待办队列,确认 distinct 后出队', () =>
412
419
  assert.equal(pendingConflictsOf(scope), 0);
413
420
  });
414
421
 
422
+ await test('S12 待裁决留可重放草稿,面板确认另存后才落库', () => {
423
+ const scope = fresh('conflict-replay');
424
+ saveEntry(globalScope(), { title: '提交信息格式', summary: '用一句话说清做了什么', body: '全局约定', resolution: 'distinct' });
425
+ const near = saveEntry(scope, { title: '提交信息格式', summary: '本项目用中文写提交信息', body: '项目补充', when: '本仓库' });
426
+ assert.equal(near.needsResolution, true);
427
+ assert.equal(loadIndex(scope).entries.length, 0, '没确认前不能预写正文');
428
+ const [row] = conflictsOf(scope);
429
+ assert.equal(row.title, '提交信息格式');
430
+ assert.equal(row.draft.body, '项目补充');
431
+ assert.equal(row.draft.when, '本仓库');
432
+ const kept = resolveConflict(scope, '提交信息格式');
433
+ assert.equal(kept.ok, true);
434
+ assert.equal(pendingConflictsOf(scope), 0);
435
+ assert.equal(loadIndex(scope).entries.length, 1);
436
+ assert.equal(loadIndex(scope).entries[0].body, '项目补充');
437
+ assert.equal(dropConflict(scope, '不存在').ok, false);
438
+ });
439
+
415
440
  console.log(`crossing.test.mjs: ${passed} passed`);