@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
@@ -0,0 +1,505 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ loadPipelines,
9
+ validatePipeline,
10
+ toPipelineV1,
11
+ readPipelineDoc,
12
+ suggestRetirements,
13
+ RETIRE_AFTER_SKIPS,
14
+ RETIRE_AFTER_DETOURS,
15
+ projectLabel,
16
+ RISK_LEVELS,
17
+ ACTORS
18
+ } from "../src/pipeline.js";
19
+ import { createStore } from "../src/store.js";
20
+ import { createService } from "../src/service.js";
21
+ import { createSettings } from "../src/settings.js";
22
+ import { createApi } from "../src/api.js";
23
+
24
+ // 这组测试守的是 M.O.(可执行的项目工作流)的读侧。与其它测试不同的一点:它同时
25
+ // 是**内容锁**——仓库里真实存在的那条发版工作流必须一直读得出来、且形状合法。
26
+ // 理由:工作流本体住仓库、由人写 JSON,没有编译器兜底;一条被改坏的 JSON 要等到
27
+ // 面板打开才报错,而那时人已经以为它在那儿了。
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ // 工作流本体住**项目**里:<项目>/.mneme/pipelines/。测试跑在仓库里,项目根就是仓库根
31
+ // (dsh-mneme/ 的上一级)——它不在包里,也不该在包里,见下面那条锁。
32
+ const REPO_ROOT = join(here, "..", "..");
33
+ const REPO_PIPELINES = join(REPO_ROOT, ".mneme", "pipelines");
34
+ // 直接 loadPipelines 时要显式给作用域:默认值是 global,用错会让断言看着绿、
35
+ // 实际读的是另一层的账。
36
+ const PROJECT_SCOPE = { scope: "project", label: "mneme" };
37
+
38
+ // 起一个只注册路由、不监听端口的 api 实例(与 test/api-routes-gaps.test.js 同款)。
39
+ class FakeRes {
40
+ constructor() { this.statusCode = 200; this.body = ""; }
41
+ writeHead(code) { this.statusCode = code; return this; }
42
+ end(text) { this.body = text ?? ""; return this; }
43
+ }
44
+
45
+ function buildApi() {
46
+ const store = createStore(":memory:");
47
+ const service = createService({ store, mirror: null, config: {} });
48
+ const settings = createSettings(store.db);
49
+ const routes = [];
50
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
51
+ createApi(ctx, service, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "");
52
+ return { routes };
53
+ }
54
+
55
+ function tmpDir() {
56
+ return mkdtempSync(join(tmpdir(), "mneme-pipeline-"));
57
+ }
58
+
59
+ function withDir(fn) {
60
+ const dir = tmpDir();
61
+ try {
62
+ return fn(dir);
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ }
67
+
68
+ function minimal(overrides = {}) {
69
+ return {
70
+ id: "demo",
71
+ name: "演示",
72
+ project: "someone/repo",
73
+ version: 0,
74
+ status: "active",
75
+ done: "做完",
76
+ trigger: [{ kind: "phrase", value: "跑一下" }],
77
+ steps: [
78
+ {
79
+ id: "A1",
80
+ phase: "准备",
81
+ title: "第一步",
82
+ when: "有人要跑",
83
+ do: "做一件事",
84
+ who: "main",
85
+ tool: "true",
86
+ done: "这件事做完了",
87
+ risk: "R0",
88
+ needs: [],
89
+ why: "没有它后面没得跑",
90
+ evidence: ["上次没有它,跑到一半才发现前置没做"]
91
+ }
92
+ ],
93
+ ...overrides
94
+ };
95
+ }
96
+
97
+ test("真实工作流读得出来:目录里那条发版工作流通过校验,且引用自洽", () => {
98
+ const pipelines = loadPipelines(REPO_PIPELINES, PROJECT_SCOPE);
99
+ assert.ok(pipelines.length >= 1, "pipelines/ 下至少要有一条工作流");
100
+ const release = pipelines.find((p) => p.id === "dsh-mneme-release");
101
+ assert.ok(release, "发版工作流必须叫 dsh-mneme-release");
102
+ assert.equal(release.project, "slow-stack/mneme");
103
+ assert.equal(release.status, "active");
104
+ assert.ok(release.steps.length >= 10, "发版不是一条直线,步骤数不该少到十几步以内");
105
+
106
+ // 引用自洽:每条 needs 都能落地(悬空引用会让图渲染出一个指向虚空的箭头)。
107
+ const ids = new Set(release.steps.map((s) => s.id));
108
+ for (const step of release.steps) {
109
+ for (const need of step.needs) assert.ok(ids.has(need), `${step.id}.needs 悬空:${need}`);
110
+ assert.ok(RISK_LEVELS.includes(step.risk), `${step.id} 风险档非法`);
111
+ assert.ok(ACTORS.includes(step.who), `${step.id} 执行者非法`);
112
+ }
113
+ // 入口唯一:一张多入口的图没法当流程跑。
114
+ assert.equal(release.steps.filter((s) => s.needs.length === 0).length, 1);
115
+ });
116
+
117
+ test("回归锁:图里每一步都带着反事实对照(没有对照的步骤不该进图)", () => {
118
+ // 防的回归:#4.4 的准入判据是「没有它这次会不会失败」,不是「跑过多少次」。
119
+ // 一条只会成功、不会失败的步骤提炼出来只增加间接层——所以这个文件里每条都要
120
+ // 能指出「上次没做它,发生了什么」。这条锁会在有人往里塞空 evidence 时变红。
121
+ const [release] = loadPipelines(REPO_PIPELINES, PROJECT_SCOPE);
122
+ const bare = release.steps.filter((s) => s.evidence.length === 0).map((s) => s.id);
123
+ assert.deepEqual(bare, [], "这些步骤没有反事实对照,等于没有判据");
124
+ });
125
+
126
+ test("图里有闸门:至少一条 gate 与至少一个汇合点", () => {
127
+ // 防的回归:把「人点头」这类检查点画成普通步骤,于是图上再也看不出哪里会停;
128
+ // 以及把并行收尾的汇合拆成两条独立尾段,图就成了两支互不相干的流程。
129
+ const [release] = loadPipelines(REPO_PIPELINES, PROJECT_SCOPE);
130
+ const doc = toPipelineV1(release, {});
131
+ assert.ok(doc.nodes.some((n) => n.kind === "gate"), "一条工作流都没有闸门,说明它不可执行");
132
+ const fanIn = doc.nodes.filter((n) => doc.edges.filter((e) => e.to === n.id).length > 1);
133
+ assert.ok(fanIn.length >= 1, "没有汇合点:并行段没有收口的地方");
134
+ });
135
+
136
+ test("校验器一次把话说完:不返回「部分可用」", () => {
137
+ const broken = minimal({
138
+ id: "Bad_ID",
139
+ status: "running",
140
+ version: -1,
141
+ trigger: [],
142
+ steps: [
143
+ { id: "A1", phase: "准备", title: "x", when: "x", do: "x", who: "robot", tool: "", done: "x", risk: "R9", needs: ["Z9"], why: "x" },
144
+ { id: "A1", phase: "准备", title: "y", when: "y", do: "y", who: "main", tool: "", done: "y", risk: "R0", needs: [], why: "y" }
145
+ ]
146
+ });
147
+ assert.throws(
148
+ () => validatePipeline(broken, "broken.json"),
149
+ (error) => {
150
+ assert.equal(error.name, "PipelineError");
151
+ assert.equal(error.message.startsWith("broken.json: "), true);
152
+ const issues = error.problems.join(" | ");
153
+ // 一次全报:改一条跑一次是最消耗人的失败模式。
154
+ for (const expect of ["id 必须是小写 kebab", "status 必须是", "version 必须是", "trigger 至少要有一条", "risk 必须是", "who 必须是", "needs 指向了不存在的步骤", "id 重复"]) {
155
+ assert.ok(issues.includes(expect), `漏报:${expect}(实得 ${issues})`);
156
+ }
157
+ return true;
158
+ }
159
+ );
160
+ });
161
+
162
+ test("环被抓住:回写是新版本,不是回到自己", () => {
163
+ const cyclic = minimal({
164
+ steps: [
165
+ { id: "A1", phase: "p", title: "a", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A3"], why: "y" },
166
+ { id: "A2", phase: "p", title: "b", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A1"], why: "y" },
167
+ { id: "A3", phase: "p", title: "c", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A2"], why: "y" }
168
+ ]
169
+ });
170
+ assert.throws(() => validatePipeline(cyclic), /图里有环/);
171
+ });
172
+
173
+ test("坏文件不静默跳过:目录里有一条读不出来就整体抛", () => {
174
+ withDir((dir) => {
175
+ writeFileSync(join(dir, "ok.json"), JSON.stringify(minimal()), "utf8");
176
+ // 静默跳过最难查:面板上看着少一条,谁也不知道是没写还是写坏了。
177
+ writeFileSync(join(dir, "bad.json"), "{ not json", "utf8");
178
+ assert.throws(() => loadPipelines(dir), /bad\.json: JSON 解析失败/);
179
+ });
180
+ });
181
+
182
+ test("两条工作流撞 id:报出来且指出跟谁撞", () => {
183
+ withDir((dir) => {
184
+ writeFileSync(join(dir, "a.json"), JSON.stringify(minimal()), "utf8");
185
+ writeFileSync(join(dir, "b.json"), JSON.stringify(minimal({ name: "另一个" })), "utf8");
186
+ assert.throws(() => loadPipelines(dir), /id "demo" 与 a\.json 重复/);
187
+ });
188
+ });
189
+
190
+ test("目录不存在返回空表,不是抛错", () => {
191
+ // 面板在任何一次启动里都可能先于文件到位;这里抛错会让整块面板打不开。
192
+ assert.deepEqual(loadPipelines(join(tmpdir(), "definitely-not-there-" + Date.now())), []);
193
+ });
194
+
195
+ test("投影:节点种类、边与 needs 一一对应", () => {
196
+ const pipeline = validatePipeline(minimal({
197
+ steps: [
198
+ { id: "A1", phase: "准备", title: "入口", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] },
199
+ { id: "A2", phase: "准备", title: "闸门", when: "w", do: "d", who: "human", tool: "", done: "x", risk: "R3", needs: ["A1"], why: "y", evidence: ["e"], gate: true },
200
+ { id: "A3", phase: "收尾", title: "汇合", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R1", needs: ["A1", "A2"], why: "y", evidence: ["e"] }
201
+ ]
202
+ }));
203
+ const doc = toPipelineV1(pipeline, {});
204
+ assert.equal(doc.contract, "mneme.pipeline/v1");
205
+ assert.deepEqual(doc.nodes.map((n) => n.kind), ["step", "gate", "branch"]);
206
+ assert.equal(doc.edges.length, 3);
207
+ assert.deepEqual(doc.edges.map((e) => e.id).sort(), ["A1->A2", "A1->A3", "A2->A3"]);
208
+ // 这份对象是手工 validate 出来的,没走过 loadPipelines,因此**没有来源**。
209
+ // 空着是实话:默认成 "project" 会让来路不明的工作流看起来像本项目的。
210
+ assert.equal(doc.pipeline.scope, null);
211
+ assert.equal(doc.pipeline.scopeLabel, null);
212
+ });
213
+
214
+ test("节点状态由对照数据推出,不由作者写死", () => {
215
+ const pipeline = validatePipeline(minimal({
216
+ steps: [
217
+ { id: "A1", phase: "p", title: "从没跑过", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] },
218
+ { id: "A2", phase: "p", title: "跑过没砸", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A1"], why: "y", evidence: ["e"] },
219
+ { id: "A3", phase: "p", title: "砸过", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A2"], why: "y", evidence: ["e"] },
220
+ { id: "A4", phase: "p", title: "很久没碰", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A3"], why: "y", evidence: ["e"] }
221
+ ]
222
+ }));
223
+ const now = Date.parse("2026-10-15T00:00:00Z");
224
+ const doc = toPipelineV1(pipeline, {
225
+ now,
226
+ runs: [
227
+ { id: "r1", steps: [{ id: "A2", at: "2026-10-14T00:00:00Z" }, { id: "A3", at: "2026-10-14T00:00:00Z", refuted: true }, { id: "A4", at: "2026-01-01T00:00:00Z" }] }
228
+ ]
229
+ });
230
+ const state = Object.fromEntries(doc.nodes.map((n) => [n.id, n.state]));
231
+ assert.deepEqual(state, { A1: "candidate", A2: "trusted", A3: "refuted", A4: "stale" });
232
+ // refuted 优先于 stale:被推翻过的步骤不该因为「太久没用」而被降级成中性的 stale。
233
+ assert.equal(doc.nodes.find((n) => n.id === "A3").credit.refuted, 1);
234
+ });
235
+
236
+ test("没有运行记录时,每个节点都是 candidate(这是实话,不是占位)", () => {
237
+ const [release] = loadPipelines(REPO_PIPELINES, PROJECT_SCOPE);
238
+ const doc = toPipelineV1(release, {});
239
+ assert.equal(doc.runs.length, 0);
240
+ assert.deepEqual([...new Set(doc.nodes.map((n) => n.state))], ["candidate"]);
241
+ });
242
+
243
+ test("readPipelineDoc:认不出 id 时不编造,但要列出有什么", () => {
244
+ const doc = readPipelineDoc({ projectRoot: REPO_ROOT, id: "no-such-thing" });
245
+ assert.equal(doc.pipeline, null);
246
+ assert.deepEqual(doc.nodes, []);
247
+ assert.deepEqual(doc.available.map((p) => p.id), ["dsh-mneme-release"]);
248
+ });
249
+
250
+ test("作用域标签对两种分隔符一视同仁:Windows 路径不得整条画进面板", () => {
251
+ // 回归锁,指向一个**只在 posix 上现形**的缺陷:原实现借 path.basename() 取目录名,
252
+ // 它跟着运行平台走——Linux 上一条 Windows 路径会被整条当成标签,而面板是会被
253
+ // 截图、被贴进 issue 的那一面,标签里会带着本机用户名。
254
+ //
255
+ // **这条锁在 Windows 上是绿的、也永远抓不住回退**:win32.basename 两种分隔符都认,
256
+ // 旧实现在本机结果就是对的。它的杀伤力只在 ubuntu 矩阵腿上——别当成冗余删掉。
257
+ const win = projectLabel("C:\\Users\\someone\\Desktop\\dsh-mneme");
258
+ const fwd = projectLabel("C:/Users/someone/Desktop/dsh-mneme");
259
+ const posix = projectLabel("/home/someone/Desktop/dsh-mneme");
260
+ assert.equal(win, "dsh-mneme", "反斜杠路径要取到目录名");
261
+ assert.equal(fwd, "dsh-mneme", "正斜杠路径要取到目录名");
262
+ assert.equal(posix, "dsh-mneme", "posix 路径行为不变");
263
+ assert.equal(projectLabel("/home/someone/Desktop/dsh-mneme/"), "dsh-mneme", "尾随分隔符不改变结果");
264
+ assert.equal(win.includes("someone"), false, "标签里不得出现用户名");
265
+ assert.equal(/[\\/]/.test(win), false, "标签里不得残留分隔符");
266
+ // 取不到目录名时按原样回退(与 basename 的 "" 语义一致)。
267
+ assert.equal(projectLabel("/"), "/", "根路径回退原串");
268
+ assert.equal(projectLabel(""), "", "空串回退原串");
269
+ });
270
+
271
+
272
+ test("清单行自带规模与作用域,够第一屏用(不必先读图)", () => {
273
+ const doc = readPipelineDoc({ projectRoot: REPO_ROOT });
274
+ const [row] = doc.available;
275
+ assert.equal(row.scope, "project");
276
+ assert.equal(row.scopeLabel, basename(REPO_ROOT));
277
+ assert.equal(row.steps, 19);
278
+ // 对照覆盖率是这一屏最要紧的数字:19 步就该有 19 条反事实。
279
+ assert.equal(row.contrast, row.steps);
280
+ assert.deepEqual(row.states, { candidate: 19, trusted: 0, stale: 0, refuted: 0 });
281
+ });
282
+
283
+ test("工作流本体住项目里,不进 npm 包", () => {
284
+ // 两头都防:
285
+ // · 打进包 → 装完插件之后**每个项目**都能读到它,「项目级」又悄悄退回全局级,
286
+ // 而这一幕在任何一次本地测试里都看不出来(测试读的是仓库目录);
287
+ // · 只锁「文件存在」不锁「不在包里」→ 上面那条会复发,没人拦。
288
+ const pkg = JSON.parse(
289
+ String(process.getBuiltinModule("node:fs").readFileSync(join(here, "..", "package.json"), "utf8"))
290
+ );
291
+ assert.ok(existsSync(REPO_PIPELINES), "项目层的 .mneme/pipelines/ 必须存在");
292
+ assert.ok(
293
+ !(pkg.files || []).some((f) => f === "pipelines" || f === "pipelines/"),
294
+ "package.json 的 files 不许含 pipelines——那会把项目工作流变成随插件到处跑的全局工作流"
295
+ );
296
+ });
297
+
298
+ test("同 id 两处都有:项目层赢,被遮蔽的那份要报出来而不是消失", () => {
299
+ // 「默认项目级」这句话的落点就在这一条:同一条工作流,项目里那份说话。
300
+ withDir((root) => {
301
+ const dir = join(root, ".mneme", "pipelines");
302
+ mkdirSync(dir, { recursive: true });
303
+ const base = JSON.parse(readFileSync(join(REPO_PIPELINES, "dsh-mneme-release.json"), "utf8"));
304
+ writeFileSync(join(dir, "dsh-mneme-release.json"), JSON.stringify({ ...base, name: "项目版" }), "utf8");
305
+ const doc = readPipelineDoc({ projectRoot: root, pluginDir: REPO_PIPELINES });
306
+ assert.equal(doc.pipeline.title, "项目版");
307
+ // 被遮蔽的那份要**报出来**,不能就这么消失:否则以后有人往全局层加一条同名工作流、
308
+ // 发现它「不见了」,会去翻文件系统而不是查这条遮蔽记录。
309
+ assert.deepEqual(doc.shadowed, [{ id: "dsh-mneme-release", hidden: "global", by: "project" }]);
310
+ assert.equal(doc.sources[0].scope, "project");
311
+ assert.equal(doc.sources[1].scope, "global");
312
+ // 作用域标签只取目录名:绝对路径里有本机用户名,而面板是会被截图的那一面。
313
+ assert.equal(doc.pipeline.scopeLabel, basename(root));
314
+ });
315
+ });
316
+
317
+ test("项目层有坏文件:不拖死全局层,但要报出来", () => {
318
+ withDir((root) => {
319
+ const dir = join(root, ".mneme", "pipelines");
320
+ mkdirSync(dir, { recursive: true });
321
+ writeFileSync(join(dir, "broken.json"), "{ not json", "utf8");
322
+ const doc = readPipelineDoc({ projectRoot: root, pluginDir: REPO_PIPELINES });
323
+ assert.ok(doc.pipeline, "全局层那份还在");
324
+ assert.equal(doc.unreadable.length, 1);
325
+ assert.equal(doc.unreadable[0].scope, "project");
326
+ // 报出来要能定位到文件:只写「读不了」等于让人自己满目录去找。
327
+ assert.match(doc.unreadable[0].detail, /broken\.json/);
328
+ });
329
+ });
330
+
331
+ test("不给项目根就只读全局层,如实标出来——绝不回退到进程目录", () => {
332
+ // 回退会把「读不到」变成「读到别的地方」,而后者在界面上看不出来,是最难查的一类错。
333
+ const doc = readPipelineDoc({ projectRoot: null, pluginDir: REPO_PIPELINES });
334
+ assert.deepEqual(doc.sources.map((s) => s.scope), ["global"]);
335
+ assert.ok(doc.pipeline);
336
+ });
337
+
338
+ test("路由 /api/dsh-mneme/pipeline:读得出、认不出 id 时不编造、坏文件说清坏在哪", async () => {
339
+ // 接线锁:读侧再好,没人注册路由也到不了面板。用真 createApi 走一遍,
340
+ // 而不是断言源码里出现过某个字符串——路径改了、参数传错了都要能被抓。
341
+ const api = buildApi();
342
+ const route = api.routes.find((r) => r.path === "/api/dsh-mneme/pipeline");
343
+ assert.ok(route, "路由没注册");
344
+
345
+ // 不给 project:只读全局层。发布包里没有 pipelines/,所以这里就该是空的——
346
+ // 空是实话,不是错误(面板据此画空态,而不是画成「读不出来」)。
347
+ const noProject = new FakeRes();
348
+ route.handler({ url: "/api/dsh-mneme/pipeline", method: "GET", headers: {} }, noProject);
349
+ assert.equal(noProject.statusCode, 200);
350
+ assert.equal(JSON.parse(noProject.body).pipeline, null);
351
+
352
+ // 给 project:读项目层。
353
+ const ok = new FakeRes();
354
+ route.handler({
355
+ url: "/api/dsh-mneme/pipeline?project=" + encodeURIComponent(REPO_ROOT),
356
+ method: "GET",
357
+ headers: {}
358
+ }, ok);
359
+ assert.equal(ok.statusCode, 200);
360
+ const doc = JSON.parse(ok.body);
361
+ assert.equal(doc.contract, "mneme.pipeline/v1");
362
+ assert.equal(doc.pipeline.id, "dsh-mneme-release");
363
+ assert.equal(doc.sources[0].scope, "project");
364
+ assert.ok(doc.nodes.length > 0);
365
+
366
+ // 相对路径**拒掉**而不是忽略:忽略了会静默退成「全局」,而全局看起来一切正常
367
+ // ——那正是这一刀要修掉的那类错。
368
+ const relative = new FakeRes();
369
+ route.handler({ url: "/api/dsh-mneme/pipeline?project=dsh-mneme", method: "GET", headers: {} }, relative);
370
+ assert.equal(relative.statusCode, 400);
371
+ assert.equal(JSON.parse(relative.body).error, "project-not-absolute");
372
+
373
+ const missing = new FakeRes();
374
+ route.handler({
375
+ url: "/api/dsh-mneme/pipeline?id=nope&project=" + encodeURIComponent(REPO_ROOT),
376
+ method: "GET",
377
+ headers: {}
378
+ }, missing);
379
+ assert.equal(JSON.parse(missing.body).pipeline, null);
380
+ });
381
+
382
+ // ── 绕行与退役提议:跳过 ≠ 绕开 ──────────────────────────────────────────────
383
+ // 这一版的核心区分。混在一起数,两种提议就都发不出来:
384
+ // 跳过 = 这次不用这一步(图上还在)→ 提议删掉**这一步**
385
+ // 绕开 = 这条路整个不对(图该重画)→ 提议重看**这条路径**
386
+
387
+ test("跳过与绕开分开计数,谁都不冒充谁", () => {
388
+ const pipeline = validatePipeline(minimal({
389
+ steps: [
390
+ { id: "A1", phase: "p", title: "a", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] },
391
+ { id: "A2", phase: "p", title: "b", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A1"], why: "y", evidence: ["e"] }
392
+ ]
393
+ }));
394
+ const doc = toPipelineV1(pipeline, {
395
+ runs: [
396
+ { id: "r1", steps: [{ id: "A1", skipped: true }, { id: "A2", detoured: true }] },
397
+ { id: "r2", steps: [{ id: "A1", skipped: true }, { id: "A2", detoured: true }] }
398
+ ]
399
+ });
400
+ const a1 = doc.nodes.find((n) => n.id === "A1");
401
+ const a2 = doc.nodes.find((n) => n.id === "A2");
402
+ assert.equal(a1.credit.skipped, 2, "A1 是被跳过");
403
+ assert.equal(a1.credit.detoured, 0, "跳过不该被算成绕开");
404
+ assert.equal(a2.credit.detoured, 2, "A2 是被绕开");
405
+ assert.equal(a2.credit.skipped, 0, "绕开不该被算成跳过");
406
+ // 两者都不算「用了这一步」——绕行和跳过都没走那一步。
407
+ assert.equal(a1.credit.uses, 0);
408
+ assert.equal(a2.credit.uses, 0);
409
+ });
410
+
411
+ test("绕行不染色:被绕开不等于这一步坏了", () => {
412
+ // 染色要留给真正被推翻过的(refuted)——那是唯一有硬判据的信号。
413
+ // 把「被绕开」也染红,会让图上到处都是红点,人就不看了。
414
+ const pipeline = validatePipeline(minimal({
415
+ steps: [
416
+ { id: "A1", phase: "p", title: "a", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] }
417
+ ]
418
+ }));
419
+ const doc = toPipelineV1(pipeline, { runs: [{ id: "r1", steps: [{ id: "A1", detoured: true }] }] });
420
+ assert.equal(doc.nodes[0].state, "candidate", "绕开不改变状态,只累加计数");
421
+ assert.equal(doc.nodes[0].credit.detoured, 1);
422
+ });
423
+
424
+ test(`跳过 ≥${RETIRE_AFTER_SKIPS} 次 → 提议删掉这一步`, () => {
425
+ const pipeline = validatePipeline(minimal({
426
+ steps: [
427
+ { id: "A1", phase: "p", title: "这一步其实是多余的", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] },
428
+ { id: "A2", phase: "p", title: "b", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A1"], why: "y", evidence: ["e"] }
429
+ ]
430
+ }));
431
+ const runs = [{ id: "r1", steps: [{ id: "A1", skipped: true }, { id: "A2" }] },
432
+ { id: "r2", steps: [{ id: "A1", skipped: true }, { id: "A2" }] },
433
+ { id: "r3", steps: [{ id: "A1", skipped: true }, { id: "A2" }] }];
434
+ const s = suggestRetirements(pipeline, { runs });
435
+ assert.equal(s.length, 1);
436
+ assert.equal(s[0].kind, "drop-step");
437
+ assert.equal(s[0].stepId, "A1");
438
+ assert.match(s[0].because, /跳过 3 次/);
439
+ });
440
+
441
+ test(`绕开 ≥${RETIRE_AFTER_DETOURS} 次 → 提议重看这条路径(不是删节点)`, () => {
442
+ const pipeline = validatePipeline(minimal({
443
+ steps: [
444
+ { id: "A1", phase: "p", title: "从这里开始岔出去", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] },
445
+ { id: "A2", phase: "p", title: "b", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A1"], why: "y", evidence: ["e"] }
446
+ ]
447
+ }));
448
+ const runs = [{ id: "r1", steps: [{ id: "A1", detoured: true }, { id: "A2" }] },
449
+ { id: "r2", steps: [{ id: "A1", detoured: true }, { id: "A2" }] },
450
+ { id: "r3", steps: [{ id: "A1", detoured: true }, { id: "A2" }] }];
451
+ const s = suggestRetirements(pipeline, { runs });
452
+ assert.equal(s.length, 1);
453
+ assert.equal(s[0].kind, "rework-path", "绕开动的是「路径」这一层,不是删掉那个点");
454
+ assert.match(s[0].effect, /路径/);
455
+ });
456
+
457
+ test("没到阈值就不提议;两种提议可以同时出现但各自独立", () => {
458
+ const pipeline = validatePipeline(minimal({
459
+ steps: [
460
+ { id: "A1", phase: "p", title: "a", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] },
461
+ { id: "A2", phase: "p", title: "b", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["A1"], why: "y", evidence: ["e"] }
462
+ ]
463
+ }));
464
+ assert.deepEqual(suggestRetirements(pipeline, { runs: [{ id: "r1", steps: [{ id: "A1", skipped: true }] }] }), [],
465
+ "跳过一次不该提议");
466
+ const runs = [{ id: "r1", steps: [{ id: "A1", skipped: true, detoured: true }] },
467
+ { id: "r2", steps: [{ id: "A1", skipped: true, detoured: true }] },
468
+ { id: "r3", steps: [{ id: "A1", skipped: true, detoured: true }] }];
469
+ const s = suggestRetirements(pipeline, { runs });
470
+ assert.deepEqual(s.map((x) => x.kind).sort(), ["drop-step", "rework-path"], "同一节点上两种提议互不吞并");
471
+ });
472
+
473
+ test("readPipelineDoc 带上 suggestions(没到阈值时是空数组,不是缺字段)", () => {
474
+ const doc = readPipelineDoc({ projectRoot: REPO_ROOT });
475
+ assert.ok(Array.isArray(doc.suggestions), "字段必须在——缺字段会让面板分不清「没有」和「读不到」");
476
+ });
477
+
478
+ // ── 候选路由:与工作流路由是两处不同的东西 ──────────────────────────────────
479
+
480
+ test("路由 /api/dsh-mneme/candidates:读候选层,且把三条边界写进契约", async () => {
481
+ const api = buildApi();
482
+ const route = api.routes.find((r) => r.path === "/api/dsh-mneme/candidates");
483
+ assert.ok(route, "候选路由没注册");
484
+
485
+ const ok = new FakeRes();
486
+ route.handler({
487
+ url: "/api/dsh-mneme/candidates?project=" + encodeURIComponent(REPO_ROOT),
488
+ method: "GET",
489
+ headers: {}
490
+ }, ok);
491
+ assert.equal(ok.statusCode, 200);
492
+ const doc = JSON.parse(ok.body);
493
+ assert.equal(doc.contract, "mneme.candidates/v1");
494
+ // 「这一刀不做的事」写进契约,免得以后有人以为忘了做。
495
+ assert.deepEqual(doc.boundaries, { autoPromote: false, autoApprove: false, injectIntoMemory: false });
496
+ // 候选住本地,不进仓库——契约里给的是本地的家,不是 <项目>/.mneme/。
497
+ assert.match(doc.dir, /candidates/);
498
+ assert.equal(doc.dir.includes(REPO_ROOT), false, "候选目录不该等于项目仓库目录");
499
+
500
+ // 相对路径与工作流路由同一口径:拒掉,不静默退成「没有项目」。
501
+ const relative = new FakeRes();
502
+ route.handler({ url: "/api/dsh-mneme/candidates?project=dsh-mneme", method: "GET", headers: {} }, relative);
503
+ assert.equal(relative.statusCode, 400);
504
+ assert.equal(JSON.parse(relative.body).error, "project-not-absolute");
505
+ });
@@ -22,7 +22,7 @@ assert.equal(findProfileScopeSegment("/a/b/profiles/x"), "x", "returns the scope
22
22
  // 负例:默认落点与普通数据目录
23
23
  for (const dir of [
24
24
  "/home/user/.dsh/memory", // 默认值(issue:默认本身在 profiles 之外)
25
- "C:\\Users\\石晴\\AppData\\Local\\memory", // Windows 普通目录
25
+ "C:\\Users\\u\\AppData\\Local\\memory", // Windows 普通目录
26
26
  "/srv/data/memory", // issue 验收用例:…/data/memory/
27
27
  "/srv/data/profiles", // profiles 是最后一段 = 没有作用域目录,不算命中
28
28
  "/srv/data/myprofiles/demo", // 子串不算:段必须整段等于 profiles