@modusensus/dsh-mneme 0.8.16 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +91 -16
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +8 -2
  4. package/docs/CONFIGURATION.md +27 -1
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/SEMANTIC.md +3 -3
  7. package/docs/WORKFLOWS.md +299 -0
  8. package/lib/api-standalone.js +78 -12
  9. package/lib/api.js +403 -62
  10. package/lib/candidates.js +454 -0
  11. package/lib/capabilities.js +819 -0
  12. package/lib/client.js +2294 -423
  13. package/lib/config.js +63 -6
  14. package/lib/continuity.js +35 -6
  15. package/lib/dream/decisions.js +17 -1
  16. package/lib/dream/sleep.js +10 -2
  17. package/lib/dream.js +5 -1
  18. package/lib/embedding.js +116 -15
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/index.js +81 -12
  21. package/lib/inject.js +40 -4
  22. package/lib/lang.js +124 -0
  23. package/lib/pipeline-activities.js +178 -0
  24. package/lib/pipeline-attribution.js +165 -0
  25. package/lib/pipeline-command.js +279 -0
  26. package/lib/pipeline-confirm.js +142 -0
  27. package/lib/pipeline-exec.js +174 -0
  28. package/lib/pipeline-nudge.js +1281 -0
  29. package/lib/pipeline-observations.js +181 -0
  30. package/lib/pipeline-observe.js +133 -0
  31. package/lib/pipeline-runner.js +346 -0
  32. package/lib/pipeline-runs.js +442 -0
  33. package/lib/pipeline.js +655 -0
  34. package/lib/scope.js +16 -4
  35. package/lib/semantic.js +66 -10
  36. package/lib/serve.js +1 -1
  37. package/lib/service.js +59 -18
  38. package/lib/session-usage.js +350 -0
  39. package/lib/settings.js +138 -1
  40. package/lib/store.js +132 -6
  41. package/lib/summarize.js +30 -0
  42. package/lib/tools.js +11 -2
  43. package/package.json +3 -3
  44. package/scripts/changelog-prep.mjs +62 -7
  45. package/scripts/check-sync.js +8 -6
  46. package/scripts/publish-gate.mjs +197 -0
  47. package/scripts/release-docs.mjs +204 -0
  48. package/scripts/release-notes.mjs +162 -0
  49. package/src/api-standalone.js +78 -12
  50. package/src/api.js +403 -62
  51. package/src/candidates.js +454 -0
  52. package/src/capabilities.js +819 -0
  53. package/src/config.js +63 -6
  54. package/src/continuity.js +35 -6
  55. package/src/dream/decisions.js +17 -1
  56. package/src/dream/sleep.js +10 -2
  57. package/src/dream.js +5 -1
  58. package/src/embedding.js +116 -15
  59. package/src/entities/extractor.js +4 -0
  60. package/src/index.js +81 -12
  61. package/src/inject.js +40 -4
  62. package/src/lang.js +124 -0
  63. package/src/pipeline-activities.js +178 -0
  64. package/src/pipeline-attribution.js +165 -0
  65. package/src/pipeline-command.js +279 -0
  66. package/src/pipeline-confirm.js +142 -0
  67. package/src/pipeline-exec.js +174 -0
  68. package/src/pipeline-nudge.js +1281 -0
  69. package/src/pipeline-observations.js +181 -0
  70. package/src/pipeline-observe.js +133 -0
  71. package/src/pipeline-runner.js +346 -0
  72. package/src/pipeline-runs.js +442 -0
  73. package/src/pipeline.js +655 -0
  74. package/src/scope.js +16 -4
  75. package/src/semantic.js +66 -10
  76. package/src/serve.js +1 -1
  77. package/src/service.js +59 -18
  78. package/src/session-usage.js +350 -0
  79. package/src/settings.js +138 -1
  80. package/src/store.js +132 -6
  81. package/src/summarize.js +30 -0
  82. package/src/tools.js +11 -2
  83. package/test/api-hardening.test.js +173 -0
  84. package/test/api.test.js +244 -7
  85. package/test/candidates.test.js +345 -0
  86. package/test/capabilities.test.js +577 -0
  87. package/test/changelog-prep.test.js +74 -8
  88. package/test/cli-enums.test.js +37 -0
  89. package/test/client.test.js +510 -2
  90. package/test/continuity.test.js +84 -0
  91. package/test/docs-config-keys.test.js +65 -0
  92. package/test/embed-ready-poll.test.js +94 -0
  93. package/test/entities.test.js +13 -2
  94. package/test/failure-query.test.js +147 -0
  95. package/test/failure-retention.test.js +105 -0
  96. package/test/graph-api.test.js +36 -2
  97. package/test/helpers/write-admission-samples.js +6 -2
  98. package/test/inject-recall.test.js +32 -4
  99. package/test/lib-smoke.test.js +20 -0
  100. package/test/llm-audit.test.js +92 -1
  101. package/test/mint-source.test.js +202 -0
  102. package/test/persona-api.test.js +172 -0
  103. package/test/persona-flag.test.js +36 -0
  104. package/test/persona-inject.test.js +145 -0
  105. package/test/persona-settings.test.js +145 -0
  106. package/test/pipeline-activities.test.js +155 -0
  107. package/test/pipeline-attribution.test.js +175 -0
  108. package/test/pipeline-command.test.js +243 -0
  109. package/test/pipeline-confirm.test.js +148 -0
  110. package/test/pipeline-exec.test.js +172 -0
  111. package/test/pipeline-nudge.test.js +1036 -0
  112. package/test/pipeline-observations.test.js +157 -0
  113. package/test/pipeline-observe.test.js +127 -0
  114. package/test/pipeline-r1r2.test.js +149 -0
  115. package/test/pipeline-runner.test.js +442 -0
  116. package/test/pipeline-runs.test.js +767 -0
  117. package/test/pipeline.test.js +636 -0
  118. package/test/placement.test.js +1 -1
  119. package/test/publish-gate.test.js +272 -0
  120. package/test/recall-retention.test.js +128 -0
  121. package/test/reindex-backfill.test.js +83 -4
  122. package/test/release-docs.test.js +130 -0
  123. package/test/release-notes.test.js +240 -0
  124. package/test/release-workflow.test.js +93 -0
  125. package/test/route-policy.test.js +113 -0
  126. package/test/scope-distill-stamp.test.js +135 -0
  127. package/test/security-doc.test.js +139 -0
  128. package/test/sensitive-scan.test.js +44 -0
  129. package/test/serve.test.js +1 -1
  130. package/test/session-usage.test.js +308 -0
  131. package/test/store-write-path-columns.test.js +191 -0
  132. package/test/summarize.test.js +176 -0
  133. package/test/supersede-state-tracking.test.js +4 -4
  134. package/test/tools.test.js +16 -1
  135. package/test/workflow-guards.test.js +156 -0
@@ -0,0 +1,145 @@
1
+ // Persona settings (user_settings "persona" key) — v1.2 多卡模型。
2
+ // Locks: legacy v1 single-object reads as default; byAgent cards round-trip;
3
+ // corruption returns empty store without throwing; unknown fields dropped.
4
+ // Whitelist lock guards the #254-style pit: a flag missing from
5
+ // FEATURE_FLAG_BOOLEANS would make the panel toggle silently ineffective.
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { createStore } from "../src/store.js";
9
+ import { createSettings } from "../src/settings.js";
10
+
11
+ function setup() {
12
+ const store = createStore(":memory:");
13
+ const settings = createSettings(store.db);
14
+ return { store, settings };
15
+ }
16
+
17
+ test("persona defaults to empty store; default card round-trips via single-object form", () => {
18
+ const { store, settings } = setup();
19
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
20
+ const input = {
21
+ name: "Amarre",
22
+ personality: "轻松幽默,讲解用类比",
23
+ replyStyle: "先给结论再解释",
24
+ enabled: false,
25
+ provenance: { sourceMemoryIds: [], generatedAt: "", reviewedByUser: false }
26
+ };
27
+ settings.setPersona(input);
28
+ const got = settings.getPersona();
29
+ assert.deepEqual(got.default, input);
30
+ assert.deepEqual(got.byAgent, {});
31
+ store.close();
32
+ });
33
+
34
+ test("default card write drops unknown fields", () => {
35
+ const { store, settings } = setup();
36
+ settings.setPersona({ name: "Amarre", sneaky: "field" });
37
+ const got = settings.getPersona();
38
+ assert.equal(got.default.name, "Amarre");
39
+ assert.equal("sneaky" in got.default, false);
40
+ store.close();
41
+ });
42
+
43
+ test("persona read of corrupt JSON returns empty store without throwing", () => {
44
+ const { store, settings } = setup();
45
+ settings.setPersona({ name: "seed" });
46
+ // 直改底层键模拟损坏(面板口径:坏值显示为空、可重写,不抛错)
47
+ store.db.prepare("UPDATE user_settings SET value = ? WHERE key = 'persona'").run("not json at all");
48
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
49
+ store.close();
50
+ });
51
+
52
+ test("persona read of non-object JSON (array/number) returns empty store", () => {
53
+ const { store, settings } = setup();
54
+ store.db.prepare("INSERT INTO user_settings (key, value) VALUES ('persona', '[1,2]')").run();
55
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
56
+ store.db.prepare("UPDATE user_settings SET value = '42' WHERE key = 'persona'").run();
57
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
58
+ store.close();
59
+ });
60
+
61
+ test("setPersona tolerates non-object input (writes empty persona)", () => {
62
+ const { store, settings } = setup();
63
+ settings.setPersona(null);
64
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
65
+ settings.setPersona("nope");
66
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
67
+ store.close();
68
+ });
69
+
70
+ // ---- v1.2 多卡模型:{ default, byAgent },旧单对象读作 default 兼容 ----------
71
+
72
+ test("v1 legacy single-object persona reads as default (zero migration)", () => {
73
+ const { store, settings } = setup();
74
+ // 直写 v1 形(单对象)模拟存量库
75
+ store.db.prepare("INSERT INTO user_settings (key, value) VALUES ('persona', ?)")
76
+ .run(JSON.stringify({ name: "Amarre", personality: "轻松幽默", enabled: true }));
77
+ const got = settings.getPersona();
78
+ assert.equal(got.default.name, "Amarre");
79
+ assert.equal(got.default.enabled, true);
80
+ assert.deepEqual(got.byAgent, {});
81
+ store.close();
82
+ });
83
+
84
+ test("byAgent card round-trips via setPersonaFor/getPersonaFor", () => {
85
+ const { store, settings } = setup();
86
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危占有", enabled: true });
87
+ assert.deepEqual(settings.getPersonaFor("zephyr"), { name: "泽菲尔", personality: "冷危占有", enabled: true });
88
+ // default 仍在且未被影响
89
+ assert.deepEqual(settings.getPersona().default, {});
90
+ // 卡列表含 zephyr
91
+ assert.deepEqual(settings.listPersonas(), ["zephyr"]);
92
+ store.close();
93
+ });
94
+
95
+ test("getPersonaFor falls back to default when id card missing or disabled", () => {
96
+ const { store, settings } = setup();
97
+ settings.setPersona({ name: "Amarre", enabled: true }); // 单对象形写 default
98
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: false }); // 卡存在但关
99
+ // 未注册身份 → default
100
+ assert.equal(settings.getPersonaFor("unknown-bot").name, "Amarre");
101
+ // 身份卡存在但 enabled=false → 落 default(兜底优先,不注入半张卡)
102
+ assert.equal(settings.getPersonaFor("zephyr").name, "Amarre");
103
+ // 身份缺失(null/空串)→ default
104
+ assert.equal(settings.getPersonaFor(null).name, "Amarre");
105
+ assert.equal(settings.getPersonaFor("").name, "Amarre");
106
+ store.close();
107
+ });
108
+
109
+ test("setPersona single-object form merges default and keeps byAgent", () => {
110
+ const { store, settings } = setup();
111
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: true });
112
+ settings.setPersona({ name: "Amarre-新", enabled: true }); // 单对象形
113
+ const got = settings.getPersona();
114
+ assert.equal(got.default.name, "Amarre-新");
115
+ assert.equal(got.byAgent.zephyr.name, "泽菲尔", "byAgent must survive a legacy-form write");
116
+ store.close();
117
+ });
118
+
119
+ test("removePersona deletes byAgent card but default is protected", () => {
120
+ const { store, settings } = setup();
121
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: true });
122
+ assert.equal(settings.removePersona("zephyr"), true);
123
+ assert.deepEqual(settings.listPersonas(), []);
124
+ assert.equal(settings.removePersona("default"), false, "default card cannot be removed");
125
+ assert.equal(settings.removePersona("ghost"), false);
126
+ store.close();
127
+ });
128
+
129
+ test("corrupt multi-card JSON reads as empty store without throwing", () => {
130
+ const { store, settings } = setup();
131
+ settings.setPersona({ name: "seed" });
132
+ store.db.prepare("UPDATE user_settings SET value = ? WHERE key = 'persona'").run("broken {");
133
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
134
+ store.close();
135
+ });
136
+
137
+ test("personaEnabled is in the feature-flag boolean whitelist", () => {
138
+ const { store, settings } = setup();
139
+ // 写侧逐键校验的合并写:未知键抛 TypeError,白名单键可用——
140
+ // 这条锁防「白名单漏登记 → 面板开关静默失效」(#254 同型坑)。
141
+ settings.setFeatureFlags({ personaEnabled: true });
142
+ assert.equal(settings.getFeatureFlags().personaEnabled, true);
143
+ assert.throws(() => settings.setFeatureFlags({ notARealFlag: true }), TypeError);
144
+ store.close();
145
+ });
@@ -0,0 +1,155 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { businessCommandOf, activityKey, tallyActivities } from "../src/pipeline-activities.js";
4
+
5
+ // 这组测试守两件事:
6
+ // ① 分组结果落在「人能扫」的量级(业务命令才进清单,shell 语法不进)
7
+ // ② 🔴 隐私硬规则:位置参数一律丢弃——回执要显示在界面上,而完整命令行里
8
+ // 几乎必然含绝对路径(路径里含本机用户名)。这一条比功能本身重要。
9
+
10
+ test("业务命令:抽出 {动词, 子命令, 旗标}", () => {
11
+ assert.deepEqual(businessCommandOf("git status"), { verb: "git", sub: "status", flags: [] });
12
+ assert.deepEqual(businessCommandOf("git push origin main"), { verb: "git", sub: "push", flags: [] });
13
+ assert.deepEqual(businessCommandOf("gh pr merge --rebase"), { verb: "gh", sub: "pr", flags: ["--rebase"] });
14
+ assert.deepEqual(businessCommandOf("npm test"), { verb: "npm", sub: "test", flags: [] });
15
+ // 子命令可空
16
+ assert.deepEqual(businessCommandOf("npm"), { verb: "npm", sub: "", flags: [] });
17
+ });
18
+
19
+ test("🔴 隐私硬规则:位置参数一律丢弃,只留动词/子命令/旗标", () => {
20
+ // 实测里真实出现过这种形状:路径含本机用户名。回执绝不能把它带出去。
21
+ const r = businessCommandOf('set-location "c:\\users\\somebody\\desktop\\proj"; git commit -m "x"');
22
+ // 整段里第一个可识别的是 set-location(不在名单)→ 由分段器处理;这里直接测 git commit
23
+ assert.equal(businessCommandOf("git commit -m \"修好了\"").verb, "git");
24
+ assert.deepEqual(businessCommandOf("git commit -m \"修好了\"").flags, ["-m"]);
25
+ // 位置参数(文件名、分支名、路径)一个都不该出现在结果里
26
+ const withPath = businessCommandOf("git add src/foo.js C:/Users/name/x.md");
27
+ assert.equal(JSON.stringify(withPath).includes("Users"), false, "路径绝不能出现在结果里");
28
+ assert.equal(JSON.stringify(withPath).includes("foo.js"), false, "文件名是位置参数,该丢");
29
+ });
30
+
31
+ test("🔴 旗标里的路径参数也要挡住(--file=C:/x 这种)", () => {
32
+ const r = businessCommandOf("git commit --file=C:/Users/name/msg.txt -m x");
33
+ assert.equal(JSON.stringify(r).includes("Users"), false, "带路径的旗标不许原样带出");
34
+ // 纯旗标照常留
35
+ assert.deepEqual(businessCommandOf("gh pr list --state all").flags.includes("--state"), true);
36
+ });
37
+
38
+ test("非业务命令一律不产清单项(shell 语法不是活动)", () => {
39
+ // 实测里密度最高的就是这些——它们不是"做了什么",是句子成分
40
+ for (const noise of [
41
+ 'write-output "=== 检查 ==="',
42
+ "select-object -first 10",
43
+ "foreach-object { $_ }",
44
+ "echo hello",
45
+ "}",
46
+ "if (test-path x) { }",
47
+ "$erroractionpreference='continue'",
48
+ "cd C:/some/dir"
49
+ ]) {
50
+ assert.equal(businessCommandOf(noise), null, `${noise} 不该进清单`);
51
+ }
52
+ });
53
+
54
+ test("分段:一条命令里的多个业务动作分别计入", () => {
55
+ const r = tallyActivities([{ command: 'cd C:/x; git status; echo "---"; npm test' }]);
56
+ const keys = r.activities.map((a) => a.key).sort();
57
+ assert.deepEqual(keys, ["git status", "npm test"], "cd 与 echo 应被排除");
58
+ assert.equal(r.shellNoise, 2, "shell 噪音要单独计数,不能与业务动作混为一谈");
59
+ assert.equal(r.totalSegments, 4);
60
+ });
61
+
62
+ test("计数与排序:按出现次数降序,同次数按名字", () => {
63
+ const r = tallyActivities([
64
+ { command: "git status" },
65
+ { command: "npm test; git status" },
66
+ { command: "git status && gh pr list" }
67
+ ]);
68
+ assert.equal(r.activities[0].key, "git status");
69
+ assert.equal(r.activities[0].count, 3);
70
+ assert.equal(r.businessSegments, 5);
71
+ });
72
+
73
+ test("旗标并集:同一动作的不同旗标合并展示,且截断", () => {
74
+ const r = tallyActivities([
75
+ { command: "gh pr merge --rebase" },
76
+ { command: "gh pr merge --squash" }
77
+ ]);
78
+ assert.equal(r.activities.length, 1);
79
+ assert.equal(r.activities[0].count, 2);
80
+ assert.deepEqual(r.activities[0].flags.sort(), ["--rebase", "--squash"]);
81
+ });
82
+
83
+ test("🔴 回归:脚本路径不得被当成子命令(端到端实测抓到的真 bug)", () => {
84
+ // 实测里出现过 `node d:\dsh\.dsh\_tmp-mo-probe.mjs` —— 原实现把整条路径当 sub,
85
+ // 于是回执里带出本机路径(可能含用户名)。这正是隐私红线要挡的东西。
86
+ const r = businessCommandOf('node d:\\dsh\\.dsh\\_tmp-probe.mjs');
87
+ assert.equal(r.verb, "node");
88
+ assert.equal(JSON.stringify(r).includes("dsh"), false, "路径绝不能出现在结果里");
89
+ assert.equal(r.sub, "", "路径不是子命令,宁可留空");
90
+ // 相对路径同理
91
+ const r2 = businessCommandOf("node test/pipeline-nudge.test.js --test");
92
+ assert.equal(r2.sub, "", "文件名不是子命令");
93
+ assert.deepEqual(r2.flags, ["--test"]);
94
+ });
95
+
96
+ test("🔴 回归:`git -C <path> log` 要扫到 log,而不是把 `-C` 的值当子命令", () => {
97
+ // 实测出现过 `git (join-path -C --oneline --porcelain` 这种读不懂的清单行。
98
+ const r = businessCommandOf('git -C C:/some/dir log --oneline -3');
99
+ assert.equal(r.verb, "git");
100
+ assert.equal(r.sub, "log", "子命令在 -C 的值之后,必须扫描而不是取第一个");
101
+ assert.equal(JSON.stringify(r).includes("some"), false, "路径不许带出");
102
+ });
103
+
104
+ test("子命令形状窄:含 $、括号、引号、长串的一律不算", () => {
105
+ for (const bad of ["$p", "$(Get-Item", "C:/x/y", "d:\\a", "averyveryverylongsubcommandname", "'\"", "{", "@{"]) {
106
+ const r = businessCommandOf(`git ${bad} status`);
107
+ // 要么扫不到(sub 空),要么扫到 status;无论如何不能是那个坏值
108
+ assert.equal(r.sub === bad.toLowerCase(), false, `${bad} 不该被当成子命令`);
109
+ }
110
+ });
111
+
112
+ test("旗标里带 = 或路径的一律丢(--file=C:/x 这种)", () => {
113
+ const r = businessCommandOf("git commit --file=C:/Users/name/msg.txt -m x");
114
+ assert.equal(JSON.stringify(r).includes("Users"), false);
115
+ assert.deepEqual(r.flags, ["-m"], "带值/路径的旗标整个丢掉");
116
+ });
117
+
118
+ test("重定向与引号不影响识别", () => {
119
+ assert.deepEqual(businessCommandOf('npm test 2>&1 | Select-String "x"'), { verb: "npm", sub: "test", flags: [] });
120
+ assert.equal(businessCommandOf("'git' 'status'").verb, "git");
121
+ });
122
+
123
+ test("坏输入不抛:空、非串、缺字段一律安全返回", () => {
124
+ for (const bad of [null, undefined, "", " ", 0, {}]) {
125
+ assert.equal(businessCommandOf(bad), null);
126
+ }
127
+ for (const bad of [null, undefined, [], [{}], [{ command: null }], [0, "x"]]) {
128
+ const r = tallyActivities(bad);
129
+ assert.deepEqual(r.activities, []);
130
+ assert.equal(typeof r.shellNoise, "number");
131
+ }
132
+ assert.equal(activityKey(null), "");
133
+ });
134
+
135
+ test("产出可读:key 是 `动词 子命令`,trim 过", () => {
136
+ assert.equal(activityKey({ verb: "git", sub: "status" }), "git status");
137
+ assert.equal(activityKey({ verb: "npm", sub: "" }), "npm");
138
+ });
139
+
140
+ test("没有子命令时用第一个旗标当键:node --test 与 node --check 不能并成一行", () => {
141
+ // 端到端实测里 16 次 node 调用被并成一条 —— 但它们语义不同(跑测试 vs 查语法)。
142
+ const r = tallyActivities([
143
+ { command: "node --test x.test.js" },
144
+ { command: "node --test y.test.js" },
145
+ { command: "node --check src/foo.js" }
146
+ ]);
147
+ const keys = r.activities.map((a) => a.key).sort();
148
+ assert.deepEqual(keys, ["node --check", "node --test"], "旗标区分语义,该分开");
149
+ assert.equal(r.activities.find((a) => a.key === "node --test").count, 2);
150
+ });
151
+
152
+ test("旗标只当补充:有子命令时键仍以子命令为准", () => {
153
+ const k = activityKey({ verb: "git", sub: "status", flags: ["-C", "--porcelain"] });
154
+ assert.equal(k, "git status", "-C 后面本来跟着被丢弃的路径,不该挤进键里");
155
+ });
@@ -0,0 +1,175 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ normalizeCommand,
5
+ commandParts,
6
+ attributionCandidates,
7
+ unattributableSteps,
8
+ normalizeObservation
9
+ } from "../src/pipeline-attribution.js";
10
+
11
+ // 这组测试守的是 M.O. 的**归属规则**:把宿主的一次工具动作映射到「可能是工作流的哪一步」。
12
+ // 它最要紧的性质不是「能匹配上」,而是**认不出来时绝不许猜**——误匹配会让错误的步骤
13
+ // 被染成可信,而漏匹配只是少一条账。所以下面失败的用例和成功的用例一样重要。
14
+
15
+ function pipeline(steps) {
16
+ return {
17
+ id: "demo",
18
+ steps: steps.map((s) => ({
19
+ id: s.id,
20
+ tool: s.tool ?? "",
21
+ who: s.who ?? "main",
22
+ gate: s.gate === true
23
+ }))
24
+ };
25
+ }
26
+
27
+ // ── 归一化 ────────────────────────────────────────────────────────────────────
28
+
29
+ test("归一化:大小写、占位符、多余空白都不影响比较", () => {
30
+ assert.equal(normalizeCommand(" NPM Test "), "npm test");
31
+ // 占位符是给人的读法,不是字面量——留着它会让 `git push <tag>` 谁都匹配不上。
32
+ assert.equal(normalizeCommand("git push <tag>"), "git push");
33
+ assert.equal(normalizeCommand("node script.mjs <版本>"), "node script.mjs");
34
+ });
35
+
36
+ test("占位符去掉而不是换成通配:不许宽到能匹配一切", () => {
37
+ // 若把 <tag> 换成 .*,下面这条就会命中——那是把「推 tag」与「推分支」混为一谈。
38
+ const p = pipeline([{ id: "T2", tool: "git push <tag>" }]);
39
+ const c = attributionCandidates({ command: "git push --force origin main" }, p);
40
+ assert.equal(c.every((x) => x.confidence !== "exact"), true, "占位符不该让任意参数都算「完全一致」");
41
+ });
42
+
43
+ test("并列命令按分隔符拆开,但路径里的斜杠不能把命令切碎", () => {
44
+ assert.deepEqual(commandParts("git log / gh pr list"), ["git log", "gh pr list"]);
45
+ assert.deepEqual(commandParts("npm run badge:sync"), ["npm run badge:sync"]);
46
+ // 这一段是防回归的关键:脚本路径里的 `/` 两侧无空白,不该被当分隔符。
47
+ assert.deepEqual(commandParts("node dsh-mneme/scripts/publish-gate.mjs <版本>"),
48
+ ["node dsh-mneme/scripts/publish-gate.mjs"]);
49
+ });
50
+
51
+ // ── 匹配 ──────────────────────────────────────────────────────────────────────
52
+
53
+ test("完全一致:命令与步骤里写的对得上,给 exact 候选", () => {
54
+ const p = pipeline([{ id: "P6", tool: "npm test" }]);
55
+ const c = attributionCandidates({ command: "npm test" }, p);
56
+ assert.equal(c.length, 1);
57
+ assert.equal(c[0].stepId, "P6");
58
+ assert.equal(c[0].confidence, "exact");
59
+ });
60
+
61
+ test("抗噪:同前缀但不同子命令的步骤不该成为候选", () => {
62
+ // 真实工作流里 `git` 出现 5 次、`gh pr` 出现 4 次。若按「第一个词相同」匹配,
63
+ // 一次 `npm test` 会把所有 npm 步骤都报成候选——噪声盖过信号,人看一眼就放弃了。
64
+ const p = pipeline([
65
+ { id: "P5", tool: "npm run badge:sync" },
66
+ { id: "P6", tool: "npm test" }
67
+ ]);
68
+ const c = attributionCandidates({ command: "npm test" }, p);
69
+ assert.deepEqual(c.map((x) => x.stepId), ["P6"], "只该报真正对得上那一条");
70
+ });
71
+
72
+ test("词前缀才算 weak:同一步多带了参数应被认出来", () => {
73
+ const p = pipeline([{ id: "P3", tool: "gh pr create" }]);
74
+ const c = attributionCandidates({ command: "gh pr create --base main --title 补开发版" }, p);
75
+ assert.equal(c.length, 1);
76
+ assert.equal(c[0].confidence, "weak", "人写的是步骤名,agent 跑的是带参命令行——这该认出来");
77
+ assert.match(c[0].why, /gh pr create/);
78
+ });
79
+
80
+ test("命令头相同但参数对不上:给 weak 候选,并如实说明差在哪", () => {
81
+ const p = pipeline([{ id: "V2", tool: "git push" }]);
82
+ const c = attributionCandidates({ command: "git push --delete origin lane" }, p);
83
+ assert.equal(c.length, 1);
84
+ assert.equal(c[0].confidence, "weak");
85
+ // 理由里必须能看出「步骤写的是什么、这次是什么」,否则人没法判断该不该认。
86
+ assert.match(c[0].why, /git push/);
87
+ assert.match(c[0].why, /--delete/);
88
+ });
89
+
90
+ test("认不出来就是空数组——不许猜", () => {
91
+ const p = pipeline([{ id: "P6", tool: "npm test" }, { id: "T2", tool: "git tag" }]);
92
+ assert.deepEqual(attributionCandidates({ command: "docker compose up" }, p), []);
93
+ // 拼不出命令行时同样返回空,而不是退回「第一条」。
94
+ assert.deepEqual(attributionCandidates({ tool: "bash" }, p), []);
95
+ });
96
+
97
+ test("同一档多条候选全部返回,不替人挑一条", () => {
98
+ const p = pipeline([
99
+ { id: "A1", tool: "npm test" },
100
+ { id: "A2", tool: "npm test" }
101
+ ]);
102
+ const c = attributionCandidates({ command: "npm test" }, p);
103
+ assert.deepEqual(c.map((x) => x.stepId), ["A1", "A2"], "两条步骤长得一样时该由人消歧,机器不许自己选");
104
+ assert.equal(c.every((x) => x.confidence === "exact"), true);
105
+ });
106
+
107
+ test("空 tool 的步骤结构性地无法归属:不参与匹配,而不是匹配成「什么都能中」", () => {
108
+ const p = pipeline([{ id: "P4", tool: "" }, { id: "P6", tool: "npm test" }]);
109
+ const c = attributionCandidates({ command: "npm test" }, p);
110
+ assert.deepEqual(c.map((x) => x.stepId), ["P6"]);
111
+ assert.deepEqual(attributionCandidates({ command: "任意命令" }, pipeline([{ id: "P4", tool: "" }])), []);
112
+ });
113
+
114
+ test("exact 排在 weak 前面(给人看时先看最可能的那档)", () => {
115
+ // 同一次动作能同时命中两种档位时,exact 必须在前——人从第一条开始看,
116
+ // 把最可能的排在后面等于逼人先读完噪声。
117
+ const p = pipeline([
118
+ { id: "W", tool: "git push" }, // 这次跑的是 `git push --force`,只算 weak
119
+ { id: "E", tool: "git push --delete origin lane" } // 这次跑的正是它,exact
120
+ ]);
121
+ const c = attributionCandidates({ command: "git push --delete origin lane" }, p);
122
+ assert.equal(c[0].stepId, "E");
123
+ assert.equal(c[0].confidence, "exact");
124
+ assert.equal(c[1].stepId, "W");
125
+ assert.equal(c[1].confidence, "weak");
126
+ });
127
+
128
+ test("弱匹配受得住真实工作流的规模:一次 git push 不会把六条 git 步骤全报出来", () => {
129
+ // 真实那条 19 步的工作流里 git 系命令有 5 条。这条是抗噪的规模版回归锁。
130
+ const real = pipeline([
131
+ { id: "P1", tool: "git log / gh pr list" },
132
+ { id: "V2", tool: "git push" },
133
+ { id: "T1", tool: "git diff" },
134
+ { id: "T2", tool: "git tag / git push <tag>" },
135
+ { id: "F3", tool: "git push --delete / git worktree remove" }
136
+ ]);
137
+ const c = attributionCandidates({ command: "git push origin main" }, real);
138
+ const ids = c.map((x) => x.stepId);
139
+ // `git log` / `git diff` / `git push --delete` 都不算 `git push`(后者参数方向相反:
140
+ // 步骤写的是「git push --delete」,这次是「git push origin main」,谁也不是谁的前缀)。
141
+ assert.equal(ids.includes("P1"), false, "git log 不该被当成 git push");
142
+ assert.equal(ids.includes("T1"), false, "git diff 不该被当成 git push");
143
+ assert.equal(ids.includes("F3"), false, "git push --delete 与 git push origin main 是不同的两步");
144
+ // 只剩真的以 `git push` 起头、只是多带了参数的那两条。
145
+ assert.deepEqual(ids.sort(), ["T2", "V2"]);
146
+ assert.ok(ids.length < real.steps.length, "候选数必须少于总步数,否则等于没筛");
147
+ });
148
+
149
+ // ── 结构性不可归属 ────────────────────────────────────────────────────────────
150
+
151
+ test("空 tool 的步骤被单独列出来:它是「为什么这一步永远没账」的答案", () => {
152
+ const p = pipeline([{ id: "P6", tool: "npm test" }, { id: "P4", tool: "" }]);
153
+ assert.deepEqual(unattributableSteps(p), ["P4"],
154
+ "没写 tool 的步骤永远不会拿到账——它与「还没跑过」必须分得开");
155
+ assert.deepEqual(unattributableSteps({ steps: [{ id: "X", tool: " " }] }), ["X"], "空白 tool 同样不可归属");
156
+ assert.deepEqual(unattributableSteps(null), [], "坏输入返回空数组,不抛");
157
+ });
158
+
159
+ // ── 观察对象的形状 ────────────────────────────────────────────────────────────
160
+
161
+ test("观察的三态 ok 必须保住:「失败」不能与「不知道」折叠成同一个值", () => {
162
+ assert.equal(normalizeObservation({ command: "npm test", ok: true }).ok, true);
163
+ assert.equal(normalizeObservation({ command: "npm test", ok: false }).ok, false);
164
+ // 宿主没给成败时留 null——把它当 false 会让「跑失败」与「没记录」不可分。
165
+ assert.equal(normalizeObservation({ command: "npm test" }).ok, null);
166
+ assert.equal(normalizeObservation({ tool: "bash" }), null, "拼不出命令行的观察不进归属");
167
+ });
168
+
169
+ test("坏输入都不抛:没有 pipeline、没有观察、空对象一律返回空数组", () => {
170
+ const p = pipeline([{ id: "A1", tool: "npm test" }]);
171
+ for (const bad of [null, undefined, {}, { command: "" }, 0]) {
172
+ assert.deepEqual(attributionCandidates(bad, p), [], `观察 ${JSON.stringify(bad)} 该返回空数组而不是抛`);
173
+ }
174
+ assert.deepEqual(attributionCandidates({ command: "x" }, null), []);
175
+ });