@modusensus/dsh-mneme 0.8.16 → 0.9.0

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 (73) hide show
  1. package/README.md +16 -3
  2. package/assets/icon.svg +2 -2
  3. package/docs/CONFIGURATION.md +2 -1
  4. package/docs/CONTINUITY.md +18 -3
  5. package/docs/WORKFLOWS.md +299 -0
  6. package/lib/api.js +124 -8
  7. package/lib/candidates.js +454 -0
  8. package/lib/capabilities.js +819 -0
  9. package/lib/client.js +1703 -182
  10. package/lib/config.js +31 -5
  11. package/lib/continuity.js +35 -6
  12. package/lib/dream/decisions.js +17 -1
  13. package/lib/dream/sleep.js +10 -2
  14. package/lib/dream.js +5 -1
  15. package/lib/entities/extractor.js +4 -0
  16. package/lib/index.js +49 -12
  17. package/lib/inject.js +6 -1
  18. package/lib/pipeline.js +534 -0
  19. package/lib/scope.js +16 -4
  20. package/lib/serve.js +1 -1
  21. package/lib/service.js +59 -18
  22. package/lib/session-usage.js +350 -0
  23. package/lib/settings.js +7 -1
  24. package/lib/store.js +55 -3
  25. package/lib/summarize.js +31 -0
  26. package/lib/tools.js +11 -2
  27. package/package.json +1 -1
  28. package/scripts/publish-gate.mjs +197 -0
  29. package/src/api.js +124 -8
  30. package/src/candidates.js +454 -0
  31. package/src/capabilities.js +819 -0
  32. package/src/config.js +31 -5
  33. package/src/continuity.js +35 -6
  34. package/src/dream/decisions.js +17 -1
  35. package/src/dream/sleep.js +10 -2
  36. package/src/dream.js +5 -1
  37. package/src/entities/extractor.js +4 -0
  38. package/src/index.js +49 -12
  39. package/src/inject.js +6 -1
  40. package/src/pipeline.js +534 -0
  41. package/src/scope.js +16 -4
  42. package/src/serve.js +1 -1
  43. package/src/service.js +59 -18
  44. package/src/session-usage.js +350 -0
  45. package/src/settings.js +7 -1
  46. package/src/store.js +55 -3
  47. package/src/summarize.js +31 -0
  48. package/src/tools.js +11 -2
  49. package/test/api.test.js +147 -7
  50. package/test/candidates.test.js +345 -0
  51. package/test/capabilities.test.js +577 -0
  52. package/test/client.test.js +361 -2
  53. package/test/continuity.test.js +84 -0
  54. package/test/embed-ready-poll.test.js +94 -0
  55. package/test/entities.test.js +13 -2
  56. package/test/failure-query.test.js +147 -0
  57. package/test/failure-retention.test.js +105 -0
  58. package/test/graph-api.test.js +36 -2
  59. package/test/inject-recall.test.js +32 -4
  60. package/test/llm-audit.test.js +92 -1
  61. package/test/mint-source.test.js +202 -0
  62. package/test/pipeline.test.js +505 -0
  63. package/test/placement.test.js +1 -1
  64. package/test/publish-gate.test.js +272 -0
  65. package/test/recall-retention.test.js +128 -0
  66. package/test/release-workflow.test.js +77 -0
  67. package/test/scope-distill-stamp.test.js +135 -0
  68. package/test/serve.test.js +1 -1
  69. package/test/session-usage.test.js +308 -0
  70. package/test/store-write-path-columns.test.js +191 -0
  71. package/test/summarize.test.js +176 -0
  72. package/test/supersede-state-tracking.test.js +4 -4
  73. package/test/tools.test.js +16 -1
package/src/tools.js CHANGED
@@ -7,6 +7,7 @@ import { matchesPlatform } from "./runtime/closure.js";
7
7
  import { verifyPayload } from "./runtime/verify.js";
8
8
  import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
9
9
  import { injectChildEnabled } from "./config.js";
10
+ import { lastUserQuery } from "./inject.js";
10
11
 
11
12
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
12
13
  // Per-registry tool-name registry: guards against duplicate registration on
@@ -381,6 +382,14 @@ export function createTools(ctx, service, config, embedder) {
381
382
  render: (_args, value) => TEXT_OUTPUT(`Updated memory ${value.memory.id}: ${value.memory.title}`)
382
383
  },
383
384
  async execute(args) {
385
+ // v0.9.0:失败表的 query 是「当时到底想干什么」唯一的落点,而 service
386
+ // 层看不到会话(它只认 ctx.query,见 src/service.js 的 saveFailure 注释)。
387
+ // 工具层是唯一同时握着改写动作与本轮会话的入口:没给 reason 就退回当轮用户
388
+ // 原话,取法与注入侧共用同一份实现(src/inject.js 的 lastUserQuery),不复制
389
+ // 第二份。两边都取不到时仍然写 NULL——空串会假装「有意图」,NULL 才说实话。
390
+ const exec = arguments[1];
391
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
392
+ const query = reason || lastUserQuery(exec) || undefined;
384
393
  const memory = service.update(args.id, {
385
394
  title: args.title,
386
395
  content: args.content,
@@ -389,10 +398,10 @@ export function createTools(ctx, service, config, embedder) {
389
398
  importance: args.importance,
390
399
  // 复核项 4(#170):strictScope 下他 scope 的行按「不存在」处理(与 get 对齐),
391
400
  // 展开恒为 undefined,仅承担写入前校验。
392
- ...(assertVisibleForTool(args.id, arguments[1])),
401
+ ...(assertVisibleForTool(args.id, exec)),
393
402
  ...(args.agent_scope !== undefined ? { agent_scope: args.agent_scope } : {}),
394
403
  ...(args.workspace_scope !== undefined ? { workspace_scope: args.workspace_scope } : {})
395
- }, { query: args.reason });
404
+ }, { query });
396
405
  return { memory: { id: memory.id, title: memory.title, content: memory.content } };
397
406
  }
398
407
  }),
package/test/api.test.js CHANGED
@@ -680,7 +680,11 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
680
680
  // #135 成本审计派生新增 dreamMaxMergePerRun(单轮 merge 决策上限))
681
681
  // issue #399 新增 distillRecords.enabled/distillRecords.retentionDays(蒸馏原文留存 +
682
682
  // 溯源:开关与保留期都在嵌套对象里,两个都进 NESTED_FLAG_PATHS))
683
- assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + 1 + 2);
683
+ // v0.9.0 新增 actionPipelineEnabled(M.O. beta 门控;顶层扁平键,
684
+ // 走 configFlagValue 默认分支,不需要 NESTED_FLAG_PATHS 条目)。
685
+ // 计数锁钉的是「白名单键 ↔ config schema 成对」:schema 加了键而漏了白名单
686
+ // (或反过来)时,effective 会静默少键/多键,面板读不到也报不出来。
687
+ assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + 1 + 2 + 1);
684
688
  assert.equal(data.effective.dreamSkipInvalid, true);
685
689
  assert.equal(data.effective.allowCrossTypeMerge, false);
686
690
  assert.equal(data.effective.dreamMergeGuard, true);
@@ -714,6 +718,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
714
718
  // 保留期默认 90 天(与 llmAudit.retentionDays 同款)。
715
719
  assert.equal(data.effective["distillRecords.enabled"], false);
716
720
  assert.equal(data.effective["distillRecords.retentionDays"], 90);
721
+ // v0.9.0:M.O. beta 门控默认关——客户端 GET /features 必须能读到这个键
722
+ // (读不到就静默不注册任何座位),且默认值必须是 false(beta opt-in)。
723
+ assert.equal(data.effective.actionPipelineEnabled, false);
717
724
  // 新增字符串 / URL / 枚举键
718
725
  assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
719
726
  assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
@@ -746,14 +753,16 @@ test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists"
746
753
  const { routes, settings } = setup(undefined, "", FLAGS_CFG);
747
754
  const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
748
755
  const res = new FakeRes();
749
- await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000 }), res);
756
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true }), res);
750
757
  assert.equal(res.statusCode, 200);
751
758
  const data = JSON.parse(res.body);
752
- assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000 });
759
+ assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
753
760
  assert.equal(data.effective.autoDream, false, "override wins over config default");
754
761
  assert.equal(data.effective.distillMaxChars, 48000);
762
+ // v0.9.0:默认关的键也能被 PUT 打开并读回(beta 功能的启用路径)。
763
+ assert.equal(data.effective.actionPipelineEnabled, true, "override flips the beta gate on");
755
764
  assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
756
- assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000 });
765
+ assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
757
766
  });
758
767
 
759
768
  test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
@@ -1435,8 +1444,12 @@ const MOCK_LLM = {
1435
1444
  if (provider === "broken") throw new Error("provider not reachable");
1436
1445
  return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
1437
1446
  },
1447
+ // 每次 stream 的 options 留档:既给「字段省略 / 透传」的断言用,也让「档位被拒
1448
+ // 后重试一次」能数出调用次数(降级必须恰好两次,多一次就是盲重试)。
1449
+ streamCalls: [],
1438
1450
  async *stream(options) {
1439
1451
  MOCK_LLM.lastOptions = options;
1452
+ MOCK_LLM.streamCalls.push(options);
1440
1453
  if (options.model === "bad-model") {
1441
1454
  yield {
1442
1455
  type: "finish",
@@ -1444,6 +1457,26 @@ const MOCK_LLM = {
1444
1457
  };
1445
1458
  return;
1446
1459
  }
1460
+ // 流级档位拒收:rc.1 起的适配器把 provider 的拒收折叠成终止 error finish
1461
+ // chunk(不抛错),这条路径曾让 withEffortFallback 的重试形同死代码。
1462
+ if (options.model === "stream-reject" && options.reasoningEffort) {
1463
+ yield {
1464
+ type: "finish",
1465
+ reason: {
1466
+ kind: "error",
1467
+ failure: {
1468
+ code: "UNSUPPORTED_REASONING_EFFORT",
1469
+ message: `provider "mock" model "mock-model" does not support reasoning effort "${options.reasoningEffort}"`
1470
+ }
1471
+ }
1472
+ };
1473
+ return;
1474
+ }
1475
+ // 抛错型真失败(网络中断 / provider 崩溃):错误以异常形式冒到 catch 路径,
1476
+ // 必须原样上报——「档位被拒才重试」不能退化成「见到错误就重试」。
1477
+ if (options.model === "crash-model") {
1478
+ throw new Error("socket hang up");
1479
+ }
1447
1480
  if (options.reasoningEffort === "reject") {
1448
1481
  throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
1449
1482
  }
@@ -1517,15 +1550,122 @@ test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", asy
1517
1550
  assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
1518
1551
  });
1519
1552
 
1520
- test("POST /api/dsh-mneme/test-model reports thrown failure with 502", async () => {
1553
+ // issue #215 的后续:探针是仓里唯一「接受并转发调用方档位、却此前没有兜底」的
1554
+ // LLM 调用面(直调 llm.stream 的其余各面要么在 withEffortFallback 之内,要么
1555
+ // 是 src/index.js 的手写版兜底,要么像 pre-inject-gate 的判定池那样不转发档位),
1556
+ // 档位被拒则丢掉字段重试一次,于是同一份配置「后台跑得通、点测试报红」,用户被
1557
+ // 引向「换哪家 Provider 都失败」。探针必须与真实 run 同语义:降级跑通 → 200 +
1558
+ // effortDropped:true(不是 502)。
1559
+ test("POST /api/dsh-mneme/test-model degrades on a rejected effort instead of failing (issue #215 follow-up)", async () => {
1560
+ const { routes } = setupLlm(MOCK_LLM);
1561
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1562
+ const before = MOCK_LLM.streamCalls.length;
1563
+ const res = new FakeRes();
1564
+ await route.handler(
1565
+ req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }),
1566
+ res
1567
+ );
1568
+ assert.equal(res.statusCode, 200, "a rejected effort is a downgrade, not a connectivity failure");
1569
+ const data = JSON.parse(res.body);
1570
+ assert.equal(data.ok, true);
1571
+ assert.equal(data.reply, "ok", "the no-effort retry actually answered");
1572
+ assert.equal(data.effortDropped, true, "the caller must be able to tell 'connected, effort dropped' from 'connected as configured'");
1573
+ const calls = MOCK_LLM.streamCalls.slice(before);
1574
+ assert.equal(calls.length, 2, "exactly one retry: attempt with the effort, then once without");
1575
+ assert.equal(calls[0].reasoningEffort, "reject", "first attempt carries the configured effort");
1576
+ assert.equal("reasoningEffort" in calls[1], false, "the retry omits the field entirely");
1577
+ });
1578
+
1579
+ test("POST /api/dsh-mneme/test-model degrades on a STREAM-level effort rejection too", async () => {
1580
+ // rc.1 起适配器不抛错,把 provider 的拒收折叠成终止 error finish chunk——只在
1581
+ // catch 里做兜底的话这条路径就是死的,探针会照旧 502。
1582
+ const { routes } = setupLlm(MOCK_LLM);
1583
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1584
+ const before = MOCK_LLM.streamCalls.length;
1585
+ const res = new FakeRes();
1586
+ await route.handler(
1587
+ req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "stream-reject", reasoningEffort: "high" }),
1588
+ res
1589
+ );
1590
+ assert.equal(res.statusCode, 200, "stream-level rejection also downgrades");
1591
+ const data = JSON.parse(res.body);
1592
+ assert.equal(data.ok, true);
1593
+ assert.equal(data.effortDropped, true);
1594
+ const calls = MOCK_LLM.streamCalls.slice(before);
1595
+ assert.equal(calls.length, 2, "stream rejection retries once without the effort");
1596
+ assert.equal("reasoningEffort" in calls[1], false);
1597
+ });
1598
+
1599
+ test("POST /api/dsh-mneme/test-model keeps real failures red and never reports a phantom downgrade", async () => {
1600
+ // 非档位原因的失败绝不能被兜底吞掉:真连不上还得是 502、还得带原始原因。
1521
1601
  const { routes } = setupLlm(MOCK_LLM);
1522
1602
  const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1603
+ const before = MOCK_LLM.streamCalls.length;
1523
1604
  const res = new FakeRes();
1524
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }), res);
1605
+ await route.handler(
1606
+ req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model", reasoningEffort: "low" }),
1607
+ res
1608
+ );
1525
1609
  assert.equal(res.statusCode, 502);
1526
1610
  const data = JSON.parse(res.body);
1527
1611
  assert.equal(data.ok, false);
1528
- assert.match(data.error, /UNSUPPORTED_REASONING_EFFORT/, "throw path surfaces the harness rejection");
1612
+ assert.match(data.error, /AUTH_FAILED/);
1613
+ assert.equal(data.effortDropped, undefined, "a genuine failure is not a downgrade");
1614
+ assert.equal(MOCK_LLM.streamCalls.length - before, 1, "non-effort failures are not retried blindly");
1615
+ });
1616
+
1617
+ test("POST /api/dsh-mneme/test-model reports a thrown transport failure as-is (catch path)", async () => {
1618
+ // 错误以异常形式到达时(socket 断开 / provider 崩溃)走 catch 路径:谎报成功不行,
1619
+ // 拿 without-effort 的裸奔重试去掩盖真故障更不行——「档位被拒才重试」不能退化成
1620
+ // 「见到错误就重试」,否则真故障会被一次盲重试糊住(还多烧一次配额)。
1621
+ const { routes } = setupLlm(MOCK_LLM);
1622
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1623
+ const before = MOCK_LLM.streamCalls.length;
1624
+ const res = new FakeRes();
1625
+ await route.handler(
1626
+ req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "crash-model", reasoningEffort: "low" }),
1627
+ res
1628
+ );
1629
+ assert.equal(res.statusCode, 502);
1630
+ const data = JSON.parse(res.body);
1631
+ assert.equal(data.ok, false);
1632
+ assert.match(data.error, /socket hang up/, "the thrown cause reaches the panel verbatim");
1633
+ assert.equal(data.effortDropped, undefined, "a thrown failure is not a downgrade");
1634
+ assert.equal(MOCK_LLM.streamCalls.length - before, 1, "a thrown failure is not retried blindly either");
1635
+ });
1636
+
1637
+ test("POST /api/dsh-mneme/test-model omits effortDropped when no effort was configured", async () => {
1638
+ const { routes } = setupLlm(MOCK_LLM);
1639
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1640
+ const res = new FakeRes();
1641
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1642
+ assert.equal(res.statusCode, 200);
1643
+ const data = JSON.parse(res.body);
1644
+ assert.equal(data.effortDropped, undefined, "no effort configured -> nothing was dropped");
1645
+ });
1646
+
1647
+ // 防分叉静态锁(与 #405 的归一逻辑同款):档位拒收的判据与降级语义全仓只有一份
1648
+ // ——dream.js 的 EFFORT_REJECT_RE + withEffortFallback。api.js 手写正则或自己做
1649
+ // 重试,两条路必然漂移(漂移方向就是「探针与服务端行为不一致」,本次修的就是这个)。
1650
+ test("api.js reuses the single effort-rejection source instead of forking it", () => {
1651
+ const src = readFileSync(new URL("../src/api.js", import.meta.url), "utf8");
1652
+ assert.match(
1653
+ src,
1654
+ /import \{[^}]*withEffortFallback[^}]*\} from "\.\/dream\.js"/,
1655
+ "the probe must reuse dream.js's withEffortFallback instead of hand-rolling a retry"
1656
+ );
1657
+ assert.match(
1658
+ src,
1659
+ /import \{[^}]*EFFORT_REJECT_RE[^}]*\} from "\.\/dream\.js"/,
1660
+ "the rejection predicate must come from dream.js (single source), not a local regex"
1661
+ );
1662
+ assert.ok(
1663
+ !/\/reasoning\[/.test(src),
1664
+ "api.js must not carry its own copy of the effort-rejection pattern"
1665
+ );
1666
+ // 降级语义只能有一处:探针内不得再出现第二个「无档位重试」判据。
1667
+ const attempts = src.split("withEffortFallback(").length - 1;
1668
+ assert.equal(attempts, 1, `the probe must route through withEffortFallback exactly once (got ${attempts})`);
1529
1669
  });
1530
1670
 
1531
1671
  test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
@@ -0,0 +1,345 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ decideDetour,
8
+ scanIrreversible,
9
+ projectKey,
10
+ candidateDir,
11
+ candidateId,
12
+ validateCandidate,
13
+ loadCandidates,
14
+ saveCandidate,
15
+ recordDetour,
16
+ maybePropose,
17
+ archiveStale,
18
+ promoteCandidate,
19
+ summarizeCandidate,
20
+ readCandidateDoc,
21
+ removeCandidate,
22
+ IRREVERSIBLE_PATTERNS,
23
+ PROMOTE_MIN_SEEN,
24
+ STALE_AFTER_DAYS
25
+ } from "../src/candidates.js";
26
+
27
+ // 这一组守的是 M.O. 的**候选层与绕行判定**——这一版解决的是「刻板的图会让 agent 失去自主性」。
28
+ // 核心规则一句话:自主性住在 R0/R1,刚性只住在它必须住的地方。
29
+ // 里面有几条是**踩过坑才写下的**(子命令误拦、requires/forbids 语义搞反),
30
+ // 所以那些用例不是举例子,是回归锁。
31
+
32
+ function withDir(fn) {
33
+ const dir = mkdtempSync(join(tmpdir(), "mo-cand-"));
34
+ try {
35
+ return fn(dir);
36
+ } finally {
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ const STEPS = [
42
+ { title: "看仓库干不干净", when: "发版前", do: "git status", done: "无未提交", risk: "R0" },
43
+ { title: "跳过 release-prep", when: "上一次已 bump", do: "手工改版本号", done: "版本号对", risk: "R1" }
44
+ ];
45
+
46
+ const okCandidate = (over = {}) => ({
47
+ id: "cand-abcdef123456",
48
+ origin: "detour",
49
+ steps: STEPS,
50
+ reason: "仓库干净,release-prep 这步多余",
51
+ counterfactual: null,
52
+ seen: [],
53
+ status: "collecting",
54
+ createdAt: new Date().toISOString(),
55
+ ...over
56
+ });
57
+
58
+ // ── 分档:B 档的核心规则 ────────────────────────────────────────────────────
59
+
60
+ test("R0/R1 且不跨闸门、兜底未命中 → 记账先走(自主性住在这里)", () => {
61
+ assert.equal(decideDetour({ risk: "R0", actions: ["gh pr view 438", "ls"] }).decidedBy, "model");
62
+ assert.equal(decideDetour({ risk: "R1", actions: ["npm test", "git status"] }).decidedBy, "model");
63
+ assert.equal(decideDetour({ risk: "R1", actions: [] }).decidedBy, "model");
64
+ });
65
+
66
+ test("R2/R3 → 停下等点头(刚性只住在这里)", () => {
67
+ assert.equal(decideDetour({ risk: "R2", actions: [] }).decidedBy, "human");
68
+ assert.equal(decideDetour({ risk: "R3", actions: [] }).decidedBy, "human");
69
+ });
70
+
71
+ test("兜底清单不看自报:自报 R0 但命令里有对外动作,照样拦", () => {
72
+ // 这条是「模型在自己上下文里给自己打分不可靠」的直接应用——不给单点判断。
73
+ const r = decideDetour({ risk: "R0", actions: ["git push origin main"] });
74
+ assert.equal(r.decidedBy, "human");
75
+ assert.equal(r.risk, "R2", "命中兜底时风险档要按较高者记,结算卡上才看得准");
76
+ assert.deepEqual(r.scan.matches.map((m) => m.id), ["git-push"]);
77
+ });
78
+
79
+ test("自报非法风险档 → 按最保守处理,不是放行", () => {
80
+ // 一个模型说「我的风险是 R9」时,正确的反应是别信它,而不是当成没风险。
81
+ assert.equal(decideDetour({ risk: "R9", actions: [] }).decidedBy, "human");
82
+ assert.equal(decideDetour({ risk: undefined, actions: [] }).decidedBy, "model");
83
+ });
84
+
85
+ test("闸门保护与风险档无关:纯只读绕行只要跨闸门也停下", () => {
86
+ // 闸门本来就放在「错了很难撤」的地方,绕开它等于把安全阀绕掉——
87
+ // 哪怕这条绕行本身全是只读动作。
88
+ const r = decideDetour({ risk: "R0", actions: ["ls"], crossesGate: true });
89
+ assert.equal(r.decidedBy, "human");
90
+ assert.match(r.reason, /闸门/);
91
+ });
92
+
93
+ // ── 兜底清单:子命令必须分得开 ──────────────────────────────────────────────
94
+
95
+ test("兜底清单分得清只读与写:同一工具的子命令,性质相反", () => {
96
+ // 回归锁:第一版把工具名整条当动作,`gh pr view`(只看一眼 PR)也被拦下了,
97
+ // 直接毁掉「自主性住在 R0/R1」。工具名 + 子命令才是动作。
98
+ const readOnly = [
99
+ "gh pr view 438", "gh pr list", "gh pr checks", "gh pr diff",
100
+ "gh issue view 12", "gh release view v1", "gh auth status",
101
+ "gh api repos/x/y", "gh repo view", "gh run list",
102
+ "git tag -l", "git status", "npm test",
103
+ "curl https://example.com", "Remove-Item x.txt"
104
+ ];
105
+ for (const cmd of readOnly) {
106
+ assert.equal(scanIrreversible([cmd]).hit, false, `只读的 ${cmd} 不该被拦`);
107
+ }
108
+ const writes = [
109
+ "git push origin main", "git tag v1.0.0", "git tag -d v1",
110
+ "gh pr create --title x", "gh pr merge 438", "gh pr close 438",
111
+ "gh issue create -t x", "gh release create v1",
112
+ "gh api -X POST /repos/x/y", "gh api -f name=v",
113
+ "gh auth login", "gh secret set FOO", "gh repo edit", "gh workflow run ci.yml",
114
+ "npm publish", "yarn publish", "pnpm publish",
115
+ "curl -X POST https://x.com", "curl --data a=1 https://x.com",
116
+ "Remove-Item -Recurse -Force x", "rm -rf build", "drop table memories"
117
+ ];
118
+ for (const cmd of writes) {
119
+ assert.equal(scanIrreversible([cmd]).hit, true, `要写的 ${cmd} 必须被拦`);
120
+ }
121
+ });
122
+
123
+ test("requires 与 forbids 的语义不能混:默认安全的动作只能用 requires", () => {
124
+ // 回归锁:`gh api` 不写方法时是 GET(安全),只有显式写方法与字段才危险。
125
+ // 用 forbids 表达「默认安全」是错的——不写方法时两个特征都不出现,
126
+ // forbids 不成立、规则照样命中,于是只读调用被误拦。
127
+ const api = IRREVERSIBLE_PATTERNS.find((p) => p.id === "gh-api-write");
128
+ assert.ok(api && Array.isArray(api.requires), "gh api 那条必须用 requires");
129
+ assert.equal(api.forbids, undefined, "同一语义不能既用 requires 又用 forbids");
130
+ // 而「默认危险、列出只读例外」的动作用 forbids。
131
+ const pr = IRREVERSIBLE_PATTERNS.find((p) => p.id === "gh-pr-write");
132
+ assert.ok(pr && Array.isArray(pr.forbids));
133
+ });
134
+
135
+ test("扫多条动作:任一命中即算命中,同一条不重复算", () => {
136
+ const r = scanIrreversible(["ls", "git push", "git push --tags"]);
137
+ assert.equal(r.hit, true);
138
+ assert.equal(r.matches.filter((m) => m.id === "git-push").length, 1);
139
+ assert.equal(scanIrreversible([]).hit, false);
140
+ assert.equal(scanIrreversible(null).hit, false);
141
+ });
142
+
143
+ // ── 候选的住址与身份 ───────────────────────────────────────────────────────
144
+
145
+ test("项目键稳定、不含绝对路径(文件名里不能有本机用户名)", () => {
146
+ const a = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
147
+ const b = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
148
+ const other = projectKey("C:\\Users\\someone\\Desktop\\another-project");
149
+ assert.equal(a, b, "同一个项目每次算到同一个键");
150
+ assert.notEqual(a, other, "不同项目必须分开");
151
+ assert.equal(a.includes("someone"), false, "不能把用户名带进文件名");
152
+ assert.ok(a.startsWith("dsh-mneme-"), "保留目录名便于人读");
153
+ assert.equal(projectKey(""), null);
154
+ assert.equal(projectKey(null), null);
155
+ });
156
+
157
+ test("项目键对两种分隔符一视同仁:Windows 路径不得退化成整条路径", () => {
158
+ // 回归锁,指向一个**只在 posix 上现形**的缺陷:原实现借 path.basename() 取目录名,
159
+ // 它跟着运行平台走——Linux 上 `C:\Users\someone\Desktop\dsh-mneme` 整条被当成
160
+ // 文件名,清洗后 C--Users-someone-Desktop-dsh-mne,用户名的残片进了目录名。
161
+ // 这正是 PR #439 里 ubuntu 四腿全红、windows 两腿全绿的那条。
162
+ //
163
+ // **这条锁在 Windows 上是绿的,且永远抓不住回退**:win32.basename 两种分隔符都认,
164
+ // 旧实现在本机结果就是对的。它的杀伤力只在 ubuntu 矩阵腿上——别删了它以为没事。
165
+ const nameOf = (key) => key.replace(/-[0-9a-f]{8}$/, "");
166
+ const bs = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
167
+ const fwd = projectKey("C:/Users/someone/Desktop/dsh-mneme");
168
+ const trail = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme\\");
169
+ assert.equal(nameOf(bs), "dsh-mneme", "反斜杠路径要取到目录名");
170
+ assert.equal(nameOf(fwd), "dsh-mneme", "正斜杠路径要取到目录名");
171
+ assert.equal(nameOf(trail), "dsh-mneme", "尾随分隔符不改变结果");
172
+ assert.equal(bs.includes("someone"), false, "用户名不得进目录名");
173
+ assert.equal(bs.includes("-Users-"), false, "整条路径不得被清洗成目录名");
174
+ assert.match(bs, /^[A-Za-z0-9._-]+$/, "目录名只许出现安全字符");
175
+ });
176
+
177
+
178
+ test("候选的家在 memoryDir 下、按项目分桶;拿不到项目根就没有家", () => {
179
+ const dir = candidateDir({ memoryDir: "~/.dsh/memory", projectRoot: "C:\\x\\y" });
180
+ assert.ok(dir && dir.includes("candidates"), "落在 candidates/ 下");
181
+ assert.equal(dir.includes("C:\\x\\y"), false, "路径不进字符串");
182
+ assert.equal(candidateDir({ memoryDir: "/tmp/m", projectRoot: null }), null);
183
+ });
184
+
185
+ test("候选 id 由步骤形状决定:同一段路走两次会落到同一条上", () => {
186
+ // 这正是「复用第二次才算类型,第一次只是实例」的落点——
187
+ // 如果每次绕行都生成新 id,第二次永远并不到第一次上,门槛就永远够不着。
188
+ const id1 = candidateId(STEPS);
189
+ const id2 = candidateId(STEPS.map((s) => ({ ...s })));
190
+ const id3 = candidateId([STEPS[0], { ...STEPS[1], title: "换了个做法" }]);
191
+ assert.equal(id1, id2);
192
+ assert.notEqual(id1, id3);
193
+ assert.match(id1, /^cand-[0-9a-f]{12}$/);
194
+ });
195
+
196
+ // ── 校验:候选松,但 reason 一个都不能少 ────────────────────────────────────
197
+
198
+ test("候选比定稿松,但「写不出理由就不许绕」是硬要求", () => {
199
+ assert.equal(validateCandidate(okCandidate()).ok, true);
200
+ // 定稿要求 id/name/project/version/status/done/trigger 一个不缺;候选只要这四样。
201
+ const minimal = { id: "cand-abcdef123456", origin: "manual", steps: STEPS, reason: "为什么", seen: [], status: "collecting" };
202
+ assert.equal(validateCandidate(minimal).ok, true, "候选不该要求定稿那一整套字段");
203
+
204
+ const noReason = validateCandidate(okCandidate({ reason: " " }));
205
+ assert.equal(noReason.ok, false);
206
+ assert.ok(noReason.problems.some((p) => p.includes("reason 必填")));
207
+
208
+ assert.equal(validateCandidate(okCandidate({ id: "bad-id" })).ok, false);
209
+ assert.equal(validateCandidate(okCandidate({ origin: "telepathy" })).ok, false);
210
+ assert.equal(validateCandidate(okCandidate({ status: "whatever" })).ok, false);
211
+ assert.equal(validateCandidate(okCandidate({ steps: [] })).ok, false);
212
+ assert.equal(validateCandidate(okCandidate({ steps: [{ title: "x" }] })).ok, false, "步骤缺字段要报出来");
213
+ assert.equal(validateCandidate(null).ok, false);
214
+ });
215
+
216
+ test("坏文件不静默跳过:与定稿同一纪律", () => {
217
+ withDir((dir) => {
218
+ saveCandidate(dir, okCandidate());
219
+ assert.equal(loadCandidates(dir).length, 1);
220
+ rmSync(join(dir, "cand-abcdef123456.json"));
221
+ writeFileSync(join(dir, "broken.json"), "{ not json", "utf8");
222
+ assert.throws(() => loadCandidates(dir), /broken\.json: JSON 解析失败/);
223
+ });
224
+ });
225
+
226
+ // ── 绕行 → 候选:两次才算数 ─────────────────────────────────────────────────
227
+
228
+ test("第一次绕行:落成候选,但还够不着提议", () => {
229
+ withDir((dir) => {
230
+ const r = recordDetour(dir, { steps: STEPS, reason: "仓库干净,这步多余", outcome: "worked", evidence: ["测试全绿"], runId: "r1" });
231
+ assert.equal(r.created, true);
232
+ assert.equal(r.promotedNow, false);
233
+ assert.equal(r.candidate.status, "collecting");
234
+ assert.equal(r.candidate.seen.length, 1);
235
+ assert.equal(readdirSync(dir).filter((f) => f.endsWith(".json")).length, 1);
236
+ });
237
+ });
238
+
239
+ test(`第二次走同一段路:并进同一条,翻成 proposed(门槛 ${PROMOTE_MIN_SEEN} 次)`, () => {
240
+ withDir((dir) => {
241
+ recordDetour(dir, { steps: STEPS, reason: "同上", outcome: "worked", evidence: ["绿"], runId: "r1" });
242
+ const b = recordDetour(dir, { steps: STEPS, reason: "同上", outcome: "worked", evidence: ["又绿"], runId: "r2" });
243
+ assert.equal(b.created, false, "不该新建——同一段路只有一条候选");
244
+ assert.equal(b.promotedNow, true);
245
+ assert.equal(b.candidate.status, "proposed");
246
+ assert.equal(b.candidate.seen.length, 2);
247
+ assert.equal(readdirSync(dir).filter((f) => f.endsWith(".json")).length, 1);
248
+ });
249
+ });
250
+
251
+ test("两次里只要有一次没跑通,就够不着提议", () => {
252
+ // 「复用第二次才算类型」的前提是**那次真的走通了**;两次都失败的路不该被提议进图。
253
+ const c = okCandidate({ seen: [{ outcome: "failed" }, { outcome: "unknown" }] });
254
+ assert.equal(maybePropose(c), false);
255
+ assert.equal(c.status, "collecting");
256
+ const d = okCandidate({ seen: [{ outcome: "failed" }, { outcome: "worked" }] });
257
+ assert.equal(maybePropose(d), true, "有一次跑通就算走通了这条路");
258
+ });
259
+
260
+ test("失败也留痕:走不通是知识,不是垃圾", () => {
261
+ withDir((dir) => {
262
+ recordDetour(dir, { steps: STEPS, reason: "试试", outcome: "failed", evidence: ["C2 直接报错"] });
263
+ const [c] = loadCandidates(dir);
264
+ assert.equal(c.seen[0].outcome, "failed");
265
+ assert.deepEqual(c.seen[0].evidence, ["C2 直接报错"]);
266
+ const row = summarizeCandidate(c);
267
+ assert.equal(row.failed, 1);
268
+ assert.equal(row.worked, 0);
269
+ });
270
+ });
271
+
272
+ // ── 归档与升格 ─────────────────────────────────────────────────────────────
273
+
274
+ test(`只走过一次、${STALE_AFTER_DAYS} 天没再被走到 → 归档;归档不删文件`, () => {
275
+ withDir((dir) => {
276
+ const old = okCandidate({
277
+ seen: [{ outcome: "worked", at: new Date(Date.now() - (STALE_AFTER_DAYS + 10) * 86400000).toISOString() }]
278
+ });
279
+ saveCandidate(dir, old);
280
+ const archived = archiveStale(dir, {});
281
+ assert.deepEqual(archived, [old.id]);
282
+ const after = loadCandidates(dir).find((c) => c.id === old.id);
283
+ assert.equal(after.status, "dropped");
284
+ assert.match(after.droppedReason, /没再被走到/);
285
+ assert.ok(readdirSync(dir).some((f) => f.startsWith(old.id)), "文件还在——留着当负清单的邻居");
286
+ });
287
+ });
288
+
289
+ test("归档只碰 collecting:够格的和已归档的都不动", () => {
290
+ withDir((dir) => {
291
+ const proposed = okCandidate({ id: "cand-proposed0001", status: "proposed", seen: [{ outcome: "worked", at: new Date(Date.now() - 100 * 86400000).toISOString() }] });
292
+ const fresh = okCandidate({ id: "cand-fresh0000001", seen: [{ outcome: "worked", at: new Date().toISOString() }] });
293
+ saveCandidate(dir, proposed);
294
+ saveCandidate(dir, fresh);
295
+ assert.deepEqual(archiveStale(dir, {}), []);
296
+ });
297
+ });
298
+
299
+ test("升格:补齐字段后必须过定稿的 validatePipeline", () => {
300
+ const cand = okCandidate();
301
+ const good = promoteCandidate(cand, { name: "发版(绕开 release-prep)", project: "slow-stack/mneme", done: "发出去且 registry 对得上" });
302
+ assert.equal(good.ok, true);
303
+ assert.equal(typeof good.pipeline.id, "string");
304
+ assert.equal(good.pipeline.id.startsWith("cand-"), false, "句柄要去掉 cand- 前缀");
305
+ assert.deepEqual(good.pipeline.steps[1].needs, ["S1"], "needs 要串成链,否则定稿那边会报图不连通");
306
+ assert.equal(good.pipeline.version, 0);
307
+ // 升格不是降低标准,是补齐标准:缺 project 照样过不了。
308
+ const bad = promoteCandidate(cand, { name: "缺 project" });
309
+ assert.equal(bad.ok, false);
310
+ assert.ok(bad.problems.some((p) => p.includes("project")));
311
+ });
312
+
313
+ test("升格带上候选的反事实——它是准入判据,不是备注", () => {
314
+ const cand = okCandidate({ counterfactual: "没有它,C 阶段会拿旧版本号发出去" });
315
+ const r = promoteCandidate(cand, { name: "x", project: "a/b" });
316
+ assert.equal(r.ok, true);
317
+ assert.deepEqual(r.pipeline.steps[0].evidence, ["没有它,C 阶段会拿旧版本号发出去"]);
318
+ });
319
+
320
+ // ── 投影与契约 ─────────────────────────────────────────────────────────────
321
+
322
+ test("清单行取整段路里最高的风险档(最坏情况才是它该被看待的档位)", () => {
323
+ const row = summarizeCandidate(okCandidate({ steps: [STEPS[0], STEPS[1], { title: "问人", when: "w", do: "d", done: "x", risk: "R2" }] }));
324
+ assert.equal(row.risk, "R2");
325
+ assert.equal(row.steps, 3);
326
+ });
327
+
328
+ test("契约把「这一刀不做的事」也写进去,免得以后有人以为忘了做", () => {
329
+ withDir((dir) => {
330
+ const doc = readCandidateDoc({ memoryDir: dir, projectRoot: "C:\\x\\y" });
331
+ assert.equal(doc.contract, "mneme.candidates/v1");
332
+ assert.deepEqual(doc.boundaries, { autoPromote: false, autoApprove: false, injectIntoMemory: false });
333
+ assert.deepEqual(doc.counts, { collecting: 0, proposed: 0, promoted: 0, dropped: 0 });
334
+ });
335
+ });
336
+
337
+ test("removeCandidate 只删指定的那条", () => {
338
+ withDir((dir) => {
339
+ saveCandidate(dir, okCandidate());
340
+ saveCandidate(dir, okCandidate({ id: "cand-second000001" }));
341
+ assert.equal(removeCandidate(dir, "cand-abcdef123456"), true);
342
+ assert.equal(loadCandidates(dir).length, 1);
343
+ assert.equal(removeCandidate(dir, "cand-nope00000000"), false);
344
+ });
345
+ });