@modusensus/dsh-mneme 0.9.0 → 0.9.2

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 (106) hide show
  1. package/README.md +82 -19
  2. package/bin/cli.mjs +69 -9
  3. package/docs/CONFIGURATION.md +48 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +339 -59
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +669 -256
  9. package/lib/config.js +50 -2
  10. package/lib/dream/sleep.js +35 -12
  11. package/lib/dream.js +86 -14
  12. package/lib/embedding.js +153 -22
  13. package/lib/index.js +32 -0
  14. package/lib/inject.js +34 -3
  15. package/lib/lang.js +124 -0
  16. package/lib/pipeline-activities.js +178 -0
  17. package/lib/pipeline-attribution.js +165 -0
  18. package/lib/pipeline-command.js +279 -0
  19. package/lib/pipeline-confirm.js +142 -0
  20. package/lib/pipeline-exec.js +174 -0
  21. package/lib/pipeline-nudge.js +1281 -0
  22. package/lib/pipeline-observations.js +181 -0
  23. package/lib/pipeline-observe.js +133 -0
  24. package/lib/pipeline-runner.js +346 -0
  25. package/lib/pipeline-runs.js +442 -0
  26. package/lib/pipeline.js +128 -7
  27. package/lib/scope-state.js +277 -0
  28. package/lib/semantic.js +66 -10
  29. package/lib/service.js +80 -3
  30. package/lib/settings.js +139 -1
  31. package/lib/store.js +216 -3
  32. package/lib/summarize.js +2 -3
  33. package/lib/tools.js +71 -0
  34. package/package.json +3 -3
  35. package/scripts/changelog-prep.mjs +62 -7
  36. package/scripts/check-sync.js +8 -6
  37. package/scripts/release-docs.mjs +204 -0
  38. package/scripts/release-notes.mjs +162 -0
  39. package/src/api-standalone.js +78 -12
  40. package/src/api.js +339 -59
  41. package/src/candidates.js +454 -454
  42. package/src/config.js +50 -2
  43. package/src/dream/sleep.js +35 -12
  44. package/src/dream.js +86 -14
  45. package/src/embedding.js +153 -22
  46. package/src/index.js +32 -0
  47. package/src/inject.js +34 -3
  48. package/src/lang.js +124 -0
  49. package/src/pipeline-activities.js +178 -0
  50. package/src/pipeline-attribution.js +165 -0
  51. package/src/pipeline-command.js +279 -0
  52. package/src/pipeline-confirm.js +142 -0
  53. package/src/pipeline-exec.js +174 -0
  54. package/src/pipeline-nudge.js +1281 -0
  55. package/src/pipeline-observations.js +181 -0
  56. package/src/pipeline-observe.js +133 -0
  57. package/src/pipeline-runner.js +346 -0
  58. package/src/pipeline-runs.js +442 -0
  59. package/src/pipeline.js +128 -7
  60. package/src/scope-state.js +277 -0
  61. package/src/semantic.js +66 -10
  62. package/src/service.js +80 -3
  63. package/src/settings.js +139 -1
  64. package/src/store.js +216 -3
  65. package/src/summarize.js +2 -3
  66. package/src/tools.js +71 -0
  67. package/test/api-hardening.test.js +173 -0
  68. package/test/api.test.js +210 -1
  69. package/test/changelog-prep.test.js +74 -8
  70. package/test/cli-enums.test.js +37 -0
  71. package/test/cli-flag-value.test.js +86 -0
  72. package/test/client.test.js +216 -6
  73. package/test/distill-tx-embed-gap.test.js +110 -0
  74. package/test/docs-config-keys.test.js +65 -0
  75. package/test/dream-maxtokens-fallback.test.js +132 -0
  76. package/test/embed-text-reasons.test.js +112 -0
  77. package/test/helpers/write-admission-samples.js +6 -2
  78. package/test/lib-smoke.test.js +20 -0
  79. package/test/persona-api.test.js +172 -0
  80. package/test/persona-flag.test.js +36 -0
  81. package/test/persona-inject.test.js +145 -0
  82. package/test/persona-settings.test.js +145 -0
  83. package/test/pipeline-activities.test.js +155 -0
  84. package/test/pipeline-attribution.test.js +175 -0
  85. package/test/pipeline-command.test.js +243 -0
  86. package/test/pipeline-confirm.test.js +148 -0
  87. package/test/pipeline-exec.test.js +172 -0
  88. package/test/pipeline-nudge.test.js +1036 -0
  89. package/test/pipeline-observations.test.js +157 -0
  90. package/test/pipeline-observe.test.js +127 -0
  91. package/test/pipeline-r1r2.test.js +149 -0
  92. package/test/pipeline-runner.test.js +442 -0
  93. package/test/pipeline-runs.test.js +767 -0
  94. package/test/pipeline.test.js +137 -6
  95. package/test/readme-claims.test.js +68 -0
  96. package/test/reindex-backfill.test.js +83 -4
  97. package/test/release-docs.test.js +130 -0
  98. package/test/release-notes.test.js +240 -0
  99. package/test/release-workflow.test.js +16 -0
  100. package/test/route-policy.test.js +113 -0
  101. package/test/scope-state.test.js +481 -0
  102. package/test/security-doc.test.js +139 -0
  103. package/test/sensitive-scan.test.js +44 -0
  104. package/test/tools-dto-schema-matrix.test.js +12 -3
  105. package/test/tools.test.js +6 -6
  106. package/test/workflow-guards.test.js +156 -0
@@ -0,0 +1,1036 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, existsSync, statSync } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ import zlib from "node:zlib";
7
+ import { loadPipelines, validatePipeline, projectPipelineDir } from "../src/pipeline.js";
8
+ import {
9
+ NUDGE_MIN_SUPPORT,
10
+ DEFAULT_NUDGE_KINDS,
11
+ PROPOSAL_STATUSES,
12
+ COUNTERFACTUAL_CHOICES,
13
+ resolveSessionDir,
14
+ forEachZstdFrame,
15
+ findSessionFiles,
16
+ observationsFromCalls,
17
+ toolCallsInLine,
18
+ readSessionObservations,
19
+ segmentObservations,
20
+ filterSegmentsByKinds,
21
+ safeCommandTokens,
22
+ riskFloorForStep,
23
+ omissionContrast,
24
+ mineRepeatedPatterns,
25
+ buildCounterfactual,
26
+ applyAnswers,
27
+ draftFromCandidate,
28
+ writeDraftPipeline,
29
+ writeFileAtomic,
30
+ proposalDir,
31
+ validateProposal,
32
+ saveProposal,
33
+ loadProposals,
34
+ summarizeProposal,
35
+ retirementProposals,
36
+ nudgeFromSessions
37
+ } from "../src/pipeline-nudge.js";
38
+
39
+ // 这组测试守的是 M.O. 的**沉淀链**:一条手法能不能从真实数据里自己长出来。
40
+ // 三条不许越的分界线(见 src/pipeline-nudge.js 文件头),测试逐条守:
41
+ // 1. 机器只写它真观察到的东西(措辞里不许出现编出来的判据,也不许漏本机路径);
42
+ // 2. 反事实判据的结论**永远不由机器给**(verdict 必须是 null,问题必须带着选项);
43
+ // 3. 只产 draft,不产 active(写盘函数见到 active 直接拒)。
44
+ // 数据面全部用临时目录里的**真会话日志形状**(多帧 zstd + jsonl tool-call),不碰本机记忆库——
45
+ // 测试要能在 CI 上跑,也不能把任何真实记忆正文带进仓库。
46
+
47
+ function tmpDir() {
48
+ return mkdtempSync(join(tmpdir(), "mneme-nudge-"));
49
+ }
50
+
51
+ function withDir(fn) {
52
+ const dir = tmpDir();
53
+ try {
54
+ return fn(dir);
55
+ } finally {
56
+ rmSync(dir, { recursive: true, force: true });
57
+ }
58
+ }
59
+
60
+ /** 一行会话日志,形状照 DSH 会话文件:`data.message.content[]` 里是 tool-call,参数是 JSON 字符串。 */
61
+ function sessionLine({ turn = 1, calls = [], time = 1700000000000 } = {}) {
62
+ const content = calls.map((c) => ({ type: "tool-call", name: c.name, arguments: JSON.stringify(c.args || {}) }));
63
+ return JSON.stringify({ time, data: { turn, message: { content } } });
64
+ }
65
+
66
+ function skill(name) {
67
+ return { name: "skill", args: { name } };
68
+ }
69
+
70
+ function pwsh(command) {
71
+ return { name: "pwsh", args: { command } };
72
+ }
73
+
74
+ /**
75
+ * 造一份会话日志。`frames` 是帧数组(每帧若干行)——**故意支持多帧**:只解第一帧是
76
+ * 本仓踩过的坑(一个 1.5 MB 文件只解出 291 字节,得出「零命中」的假结论)。
77
+ */
78
+ function writeSession(dir, sessionId, frames) {
79
+ const buf = Buffer.concat(frames.map((lines) => zlib.zstdCompressSync(Buffer.from(lines.join("\n"), "utf8"))));
80
+ const sub = join(dir, sessionId);
81
+ mkdirSync(sub, { recursive: true });
82
+ const file = join(sub, "session.v4.jsonl.zstd");
83
+ writeFileSync(file, buf);
84
+ return file;
85
+ }
86
+
87
+ test("多帧 zstd 要逐帧解:只解第一帧会静默丢掉后面的回合", () => {
88
+ withDir((dir) => {
89
+ const sessions = join(dir, "sessions");
90
+ writeSession(sessions, "sess-a", [
91
+ [sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })],
92
+ [sessionLine({ turn: 2, calls: [skill("gamma")] })]
93
+ ]);
94
+ const read = readSessionObservations({ sessionDir: sessions });
95
+ assert.equal(read.present, true);
96
+ assert.equal(read.frames, 2, "两帧都要解出来");
97
+ assert.equal(read.failedFrames, 0);
98
+ assert.equal(read.observations.length, 3, "三处调用(两帧)都要被扫到");
99
+ const turns = new Set(segmentObservations(read.observations).map((s) => s.turn));
100
+ assert.deepEqual([...turns].sort(), [1, 2], "第二帧的那个回合不能被丢");
101
+ });
102
+ });
103
+
104
+ test("截断帧:半行被丢掉,不抛错、也不把半行当成一条记录", () => {
105
+ withDir((dir) => {
106
+ const sessions = join(dir, "sessions");
107
+ const good = zlib.zstdCompressSync(Buffer.from(sessionLine({ turn: 1, calls: [skill("alpha")] }), "utf8"));
108
+ const cut = zlib.zstdCompressSync(Buffer.from(sessionLine({ turn: 2, calls: [skill("beta")] }), "utf8"));
109
+ // 第二帧截掉一半:可能解出半行,也可能解不出来——两种都允许,但不许变成一条可用记录。
110
+ const file = join(sessions, "sess-cut", "session.v4.jsonl.zstd");
111
+ mkdirSync(join(sessions, "sess-cut"), { recursive: true });
112
+ writeFileSync(file, Buffer.concat([good, cut.subarray(0, Math.floor(cut.length / 2))]));
113
+ const read = readSessionObservations({ sessionDir: sessions });
114
+ const names = read.observations.map((o) => o.name);
115
+ assert.deepEqual(names, ["alpha"], "只留完整的那一条:宁可少一条,不要错一条");
116
+ });
117
+ });
118
+
119
+ test("目录读不到与目录是空的必须能分辨(不把「读不到」显示成「没有」)", () => {
120
+ withDir((dir) => {
121
+ const missing = readSessionObservations({ sessionDir: join(dir, "nope") });
122
+ assert.equal(missing.present, false);
123
+ assert.equal(missing.scannedFiles, 0);
124
+ const empty = join(dir, "sessions");
125
+ mkdirSync(empty, { recursive: true });
126
+ const nothing = readSessionObservations({ sessionDir: empty });
127
+ assert.equal(nothing.present, true, "目录在");
128
+ assert.equal(nothing.readable, true, "空目录是可读的");
129
+ assert.equal(nothing.scannedFiles, 0);
130
+ assert.equal(nothing.observations.length, 0);
131
+ });
132
+ });
133
+
134
+ test("findSessionFiles 认所有版本的会话文件,超限时如实标截断", () => {
135
+ withDir((dir) => {
136
+ const sessions = join(dir, "sessions");
137
+ for (const [id, name] of [["s1", "session.v4.jsonl.zstd"], ["s2", "session.v3.jsonl.zstd"], ["s3", "session.jsonl.zstd"]]) {
138
+ mkdirSync(join(sessions, id), { recursive: true });
139
+ writeFileSync(join(sessions, id, name), zlib.zstdCompressSync(Buffer.from("")));
140
+ }
141
+ mkdirSync(join(sessions, "s4"), { recursive: true });
142
+ writeFileSync(join(sessions, "s4", "notes.txt"), "not a session");
143
+ const found = findSessionFiles(sessions);
144
+ assert.equal(found.files.length, 3, "不带版本号的老格式也算会话(只认 v4 会漏掉一半历史)");
145
+ const capped = findSessionFiles(sessions, { limit: 2 });
146
+ assert.equal(capped.files.length, 2);
147
+ assert.equal(capped.truncated, true, "被上限截断要报出来,否则调用方以为扫全了");
148
+ });
149
+ });
150
+
151
+ test("toolCallsInLine 只看 data.message.content,参数在 arguments 且是 JSON 字符串", () => {
152
+ const line = sessionLine({ turn: 7, calls: [skill("agent-reach")] });
153
+ const calls = toolCallsInLine(line);
154
+ assert.equal(calls.length, 1);
155
+ assert.equal(calls[0].name, "skill");
156
+ assert.equal(calls[0].turn, 7);
157
+ assert.deepEqual(calls[0].args, { name: "agent-reach" });
158
+ // 半行不是一条记录。
159
+ assert.deepEqual(toolCallsInLine(line.slice(0, 40)), []);
160
+ // 不含 tool-call 的行直接跳过(省掉一次 JSON.parse)。
161
+ assert.deepEqual(toolCallsInLine('{"data":{"message":{"content":[]}}}'), []);
162
+ });
163
+
164
+ test("能力识别:skill 取参数里的名字,mcp__<server>__<tool> 取 server,内置工具用自己的名字", () => {
165
+ const obs = observationsFromCalls([
166
+ skill("kaoyan-tutor"),
167
+ { name: "mcp__github__list_issues", args: {} },
168
+ pwsh("npm test"),
169
+ { name: "read", args: { file_path: "src/pipeline.js" } }
170
+ ]);
171
+ assert.deepEqual(obs.map((o) => `${o.kind}:${o.name}`), ["skill:kaoyan-tutor", "mcp:github", "tool:pwsh", "tool:read"]);
172
+ // 名字取不到的能力不算一次观察(对不上是要报出来的事实,不是硬凑成一条)。
173
+ assert.deepEqual(observationsFromCalls([{ name: "skill", args: {} }]), []);
174
+ });
175
+
176
+ test("分段:按回合切段,连续同一能力折叠但留下 repeats", () => {
177
+ const observations = observationsFromCalls([
178
+ skill("alpha"),
179
+ skill("alpha"),
180
+ skill("beta"),
181
+ skill("alpha")
182
+ ]);
183
+ observations[3].turn = 2;
184
+ const segments = segmentObservations(observations);
185
+ assert.equal(segments.length, 2, "跨回合要切开");
186
+ assert.deepEqual(segments[0].steps.map((s) => `${s.name}x${s.repeats}`), ["alphax2", "betax1"]);
187
+ assert.deepEqual(segments[1].steps.map((s) => s.name), ["alpha"]);
188
+ });
189
+
190
+ test("挖掘:至少两个不同会话才算候选;同一段路出现两次不算(复用第二次才算类型)", () => {
191
+ withDir((dir) => {
192
+ const sessions = join(dir, "sessions");
193
+ // A 会话里出现两次的模式,支持度只有 1 —— 这是「一条命令被复制粘贴」的情形。
194
+ writeSession(sessions, "sess-a", [
195
+ [sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })],
196
+ [sessionLine({ turn: 2, calls: [skill("alpha"), skill("beta")] })]
197
+ ]);
198
+ const one = readSessionObservations({ sessionDir: sessions });
199
+ assert.deepEqual(mineRepeatedPatterns(segmentObservations(one.observations)), [], `支持度门槛是 ${NUDGE_MIN_SUPPORT} 个不同会话`);
200
+
201
+ // 再加两个会话,凑够三个不同会话。
202
+ writeSession(sessions, "sess-b", [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]]);
203
+ writeSession(sessions, "sess-c", [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]]);
204
+ const read = readSessionObservations({ sessionDir: sessions });
205
+ const candidates = mineRepeatedPatterns(segmentObservations(read.observations));
206
+ assert.equal(candidates.length, 1);
207
+ assert.equal(candidates[0].support, 3);
208
+ assert.equal(candidates[0].occurrences, 4, "同一会话里走过两次也是事实,次数要照记");
209
+ assert.equal(candidates[0].names.length, 2);
210
+ });
211
+ });
212
+
213
+ test("默认只挖能力级:内置工具的通用动作不算手法(本机 520 份日志实测那是噪音)", () => {
214
+ withDir((dir) => {
215
+ const sessions = join(dir, "sessions");
216
+ for (const id of ["sess-a", "sess-b", "sess-c"]) {
217
+ writeSession(sessions, id, [
218
+ [sessionLine({ turn: 1, calls: [{ name: "read", args: {} }, pwsh("npm test"), { name: "read", args: {} }] })]
219
+ ]);
220
+ }
221
+ const read = readSessionObservations({ sessionDir: sessions });
222
+ const segments = segmentObservations(read.observations);
223
+ assert.deepEqual(DEFAULT_NUDGE_KINDS, ["skill", "mcp"]);
224
+ // 三个会话都出现了 read → pwsh,支持度足够高,但它是通用动作习惯,不是项目手法。
225
+ assert.deepEqual(mineRepeatedPatterns(segments), [], "默认口径下这类序列不该冒出来");
226
+ // 显式开工具级才挖得到——而且要把「同一步连着走」重新折叠起来。
227
+ const toolLevel = mineRepeatedPatterns(segments, { kinds: null });
228
+ assert.equal(toolLevel.length, 1);
229
+ assert.deepEqual(toolLevel[0].names, ["tool:read", "tool:pwsh", "tool:read"]);
230
+ });
231
+ });
232
+
233
+ test("按层过滤后要重新折叠:被抽掉的那一层不能把同一步拆成两步", () => {
234
+ const segments = [
235
+ {
236
+ sessionId: "s1",
237
+ turn: 1,
238
+ steps: [
239
+ { key: "skill:alpha", kind: "skill", name: "alpha", tool: "skill", command: "", repeats: 1 },
240
+ { key: "tool:pwsh", kind: "tool", name: "pwsh", tool: "pwsh", command: "npm test", repeats: 1 },
241
+ { key: "skill:alpha", kind: "skill", name: "alpha", tool: "skill", command: "", repeats: 2 }
242
+ ]
243
+ }
244
+ ];
245
+ const scoped = filterSegmentsByKinds(segments, ["skill", "mcp"]);
246
+ assert.equal(scoped[0].steps.length, 1, "中间那次 pwsh 抽掉后,两个 alpha 是相邻的同一步");
247
+ assert.equal(scoped[0].steps[0].repeats, 3, "重复次数要累加,不能丢");
248
+ // kinds 不是数组 = 不过滤(调用方明确要全部)。
249
+ assert.equal(filterSegmentsByKinds(segments, null).length, 1);
250
+ assert.equal(filterSegmentsByKinds(segments, null)[0].steps.length, 3);
251
+ });
252
+
253
+ test("缺席对照:统计口径跟候选自己的语汇走(中间夹着别的层也不算漏步)", () => {
254
+ withDir((dir) => {
255
+ const sessions = join(dir, "sessions");
256
+ // 三个会话:能力级序列 A→B 都在,中间夹没夹 pwsh 与「漏没漏步」无关。
257
+ writeSession(sessions, "sess-a", [[sessionLine({ turn: 1, calls: [skill("alpha"), pwsh("npm test"), skill("beta"), skill("gamma")] })]]);
258
+ writeSession(sessions, "sess-b", [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), skill("gamma")] })]]);
259
+ // 第四个会话真的漏了 B。
260
+ writeSession(sessions, "sess-d", [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("gamma")] })]]);
261
+ const read = readSessionObservations({ sessionDir: sessions });
262
+ const segments = segmentObservations(read.observations);
263
+ const [candidate] = mineRepeatedPatterns(segments);
264
+ assert.deepEqual(candidate.names, ["skill:alpha", "skill:beta", "skill:gamma"]);
265
+ const contrast = omissionContrast(candidate, segments);
266
+ assert.equal(contrast.steps.length, 1);
267
+ assert.equal(contrast.steps[0].withStep, 2, "夹着 pwsh 的那次也是走全了的");
268
+ assert.equal(contrast.steps[0].withoutStep, 1);
269
+ });
270
+ });
271
+
272
+ test("极大模式:A→B→C 与 A→B 支持度相同时只留长的那条", () => {
273
+ withDir((dir) => {
274
+ const sessions = join(dir, "sessions");
275
+ for (const id of ["sess-a", "sess-b"]) {
276
+ writeSession(sessions, id, [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), skill("gamma")] })]]);
277
+ }
278
+ const read = readSessionObservations({ sessionDir: sessions });
279
+ const candidates = mineRepeatedPatterns(segmentObservations(read.observations));
280
+ assert.equal(candidates.length, 1, "前缀模式不该跟着长模式一起推给人");
281
+ assert.deepEqual(candidates[0].names, ["skill:alpha", "skill:beta", "skill:gamma"]);
282
+ });
283
+ });
284
+
285
+ test("缺席对照:中间那一步「走过几次 / 少走几次」是能观察的事实,首尾不算", () => {
286
+ withDir((dir) => {
287
+ const sessions = join(dir, "sessions");
288
+ for (const id of ["sess-a", "sess-b", "sess-c"]) {
289
+ writeSession(sessions, id, [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), skill("gamma")] })]]);
290
+ }
291
+ // 第四个会话少走了中间那一步 —— 这就是「没有它,那次是这么过去的」。
292
+ writeSession(sessions, "sess-d", [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("gamma")] })]]);
293
+ const read = readSessionObservations({ sessionDir: sessions });
294
+ const segments = segmentObservations(read.observations);
295
+ const [candidate] = mineRepeatedPatterns(segments);
296
+ const contrast = omissionContrast(candidate, segments);
297
+ assert.equal(contrast.steps.length, 1, "只有中间的步骤算得出这种对照(首尾省略等于整段没发生)");
298
+ assert.equal(contrast.steps[0].stepId, "S2");
299
+ assert.equal(contrast.steps[0].withStep, 3);
300
+ assert.equal(contrast.steps[0].withoutStep, 1);
301
+ assert.equal(contrast.interiorOnly, true);
302
+ });
303
+ });
304
+
305
+ test("反事实判据不给结论:verdict 是 null,问题带固定选项", () => {
306
+ withDir((dir) => {
307
+ const sessions = join(dir, "sessions");
308
+ for (const id of ["sess-a", "sess-b"]) {
309
+ writeSession(sessions, id, [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]]);
310
+ }
311
+ const read = readSessionObservations({ sessionDir: sessions });
312
+ const segments = segmentObservations(read.observations);
313
+ const [candidate] = mineRepeatedPatterns(segments);
314
+ const cf = buildCounterfactual(candidate, { segments });
315
+
316
+ // 这一条是本文件的头号锁:判据**不许**由机器给结论。
317
+ assert.equal(cf.verdict, null, "「没有这条流程会不会漏步/失败」不由代码回答");
318
+ assert.match(cf.verdictNote, /判据不自动判/);
319
+ assert.equal(cf.observable.recurrence.support, 2);
320
+ assert.equal(cf.observable.omission.interiorOnly, true);
321
+ assert.equal(cf.observable.runs, null, "没有运行记录就如实报 null,不编一个 0 次");
322
+ assert.equal(cf.ask.length, candidate.steps.length, "每一步都要有一个待答的问题");
323
+ assert.deepEqual(cf.ask[0].choices, COUNTERFACTUAL_CHOICES.map((c) => c.id));
324
+ assert.ok(cf.ask[0].choices.includes("unknown"), "必须留「答不上来」——逼人二选一会造出假答案");
325
+ assert.equal(cf.ask[0].writeTo, "steps[].evidence[]");
326
+ });
327
+ });
328
+
329
+ test("回答:非法选项直接拒,合法回答写进 evidence(准入判据的载体)", () => {
330
+ const candidate = {
331
+ id: "nudge-abc123",
332
+ steps: [{ id: "S1", title: "技能:alpha", evidence: [] }, { id: "S2", title: "技能:beta", evidence: [] }]
333
+ };
334
+ const bad = applyAnswers(candidate, { S1: "looks-fine" });
335
+ assert.equal(bad.ok, false);
336
+ assert.match(bad.problems[0], /不在选项里/);
337
+ const ghost = applyAnswers(candidate, { S9: "would-fail" });
338
+ assert.equal(ghost.ok, false);
339
+ assert.match(ghost.problems[0], /不存在的步骤/);
340
+
341
+ const { ok, candidate: answered, unanswered } = applyAnswers(candidate, {
342
+ S1: { choice: "would-skip", note: "上次跳过它,收尾就漏了一步" },
343
+ S2: "unknown"
344
+ });
345
+ assert.equal(ok, true);
346
+ assert.equal(answered.status, "answered");
347
+ assert.deepEqual(unanswered, []);
348
+ assert.match(answered.steps[0].evidence[0], /^人答(\d{4}-\d{2}-\d{2}):没有它,会漏掉这一步——上次跳过它/);
349
+ // 原候选不许被就地改掉(回答是另一份东西)。
350
+ assert.deepEqual(candidate.steps[0].evidence, []);
351
+ });
352
+
353
+ test("风险下限:机械清单命中就是 R2,写工具是 R1,其余 R0;R3 机器永远不给", () => {
354
+ assert.deepEqual(riskFloorForStep({ tool: "pwsh", commands: ["git push origin main"] }), {
355
+ risk: "R2",
356
+ source: "blacklist",
357
+ hits: ["git-push"]
358
+ });
359
+ const shell = riskFloorForStep({ tool: "pwsh", commands: ["Remove-Item -Recurse -Force build"] });
360
+ assert.equal(shell.risk, "R2");
361
+ assert.equal(shell.source, "blacklist");
362
+ assert.deepEqual(riskFloorForStep({ tool: "write", commands: [] }), { risk: "R1", source: "write-tool", hits: [] });
363
+ assert.deepEqual(riskFloorForStep({ tool: "read", commands: [] }), { risk: "R0", source: "default", hits: [] });
364
+ // 多条命令里只要有一条命中,就是 R2(只看第一条会漏)。
365
+ assert.equal(riskFloorForStep({ tool: "pwsh", commands: ["npm test", "git push"] }).risk, "R2");
366
+ for (const probe of [{ tool: "pwsh", commands: [] }, { tool: "write" }, { tool: "task", commands: ["gh pr create -t x"] }]) {
367
+ assert.notEqual(riskFloorForStep(probe).risk, "R3", "R3 的定义就是「得人来定」,机器给出来就是假的");
368
+ }
369
+ });
370
+
371
+ test("安全取词:命令里的本机路径与用户名不许进草稿", () => {
372
+ const homeName = basename(homedir());
373
+ // 假路径**拼出来**而不是写成字面量:测试里也不该出现盘符路径的样子,连合成的都不留,
374
+ // 免得 review 时还要人肉分辨哪条是真的。
375
+ const fakeWin = ["C:", "Users", "fakeuser", "Desktop", "proj"].join("\\");
376
+ const tokens = safeCommandTokens(`cd ${fakeWin} && node build.mjs --watch`);
377
+ assert.ok(tokens.includes("cd"));
378
+ assert.ok(tokens.includes("node"));
379
+ assert.ok(!tokens.includes("fakeuser"), "路径段不许进标题");
380
+ assert.ok(!tokens.some((t) => t.includes("\\") || t.includes(":")));
381
+ // 家目录名本身就是本机用户名——即使它长得像普通单词也要挡住。
382
+ assert.equal(safeCommandTokens(`node ${homedir()}/x.mjs`).includes(homeName), false);
383
+ // 取不到词就返回空数组,宁可没词也不猜。
384
+ assert.deepEqual(safeCommandTokens(["C:", "Users", "fakeuser"].join("\\")), []);
385
+ });
386
+
387
+ test("草稿:过 validatePipeline、status 是 draft、没人回答时 evidence 全空", () => {
388
+ withDir((dir) => {
389
+ const sessions = join(dir, "sessions");
390
+ for (const id of ["sess-a", "sess-b"]) {
391
+ writeSession(sessions, id, [
392
+ [sessionLine({ turn: 1, calls: [skill("alpha"), { name: "mcp__github__list_issues", args: {} }, pwsh("npm test")] })]
393
+ ]);
394
+ }
395
+ const read = readSessionObservations({ sessionDir: sessions });
396
+ const segments = segmentObservations(read.observations);
397
+ // kinds: null = 连内置工具一起挖。默认只挖能力级(技能/MCP),这一步要的是三步混合的样本。
398
+ const [candidate] = mineRepeatedPatterns(segments, { kinds: null });
399
+ const made = draftFromCandidate(candidate, { project: "slow-stack/mneme" });
400
+ assert.equal(made.ok, true, made.problems.join("; "));
401
+ assert.equal(made.pipeline.status, "draft");
402
+ assert.equal(made.pipeline.project, "slow-stack/mneme");
403
+ assert.equal(made.pipeline.steps.length, 3);
404
+ assert.deepEqual(made.pipeline.steps.map((s) => s.id), ["S1", "S2", "S3"]);
405
+ assert.deepEqual(made.pipeline.steps.map((s) => s.needs), [[], ["S1"], ["S2"]]);
406
+ assert.deepEqual(made.unanswered, ["S1", "S2", "S3"], "没人回答之前,每一步都还没过准入判据");
407
+ for (const step of made.pipeline.steps) {
408
+ assert.deepEqual(step.evidence, [], "判据没答案时宁可空着,也不许编一条对照");
409
+ assert.match(step.done, /待补/, "人话要留白,不能看起来像已经定稿");
410
+ assert.match(step.why, /待补/);
411
+ }
412
+ // 人工回答过的那一步才带对照,其余照旧空着。
413
+ const answered = applyAnswers(candidate, { S2: "would-fail" });
414
+ const redraft = draftFromCandidate(answered.candidate, { project: "slow-stack/mneme" });
415
+ assert.equal(redraft.ok, true);
416
+ assert.deepEqual(redraft.unanswered, ["S1", "S3"]);
417
+ assert.match(redraft.pipeline.steps[1].evidence[0], /会失败/);
418
+ // 再走一遍校验器也不许变(归一化是幂等的)。
419
+ assert.deepEqual(validatePipeline(redraft.pipeline).steps.length, 3);
420
+ });
421
+ });
422
+
423
+ test("草稿写盘:只收 draft,不覆盖已有文件", () => {
424
+ withDir((dir) => {
425
+ const projectRoot = join(dir, "proj");
426
+ mkdirSync(projectRoot, { recursive: true });
427
+ const draft = {
428
+ id: "nudge-deadbeef",
429
+ name: "(待补)x",
430
+ project: "p",
431
+ version: 0,
432
+ status: "draft",
433
+ done: "(待补)整条怎么算完成",
434
+ trigger: [{ kind: "phrase", value: "(待补)什么话该触发" }],
435
+ steps: [
436
+ {
437
+ id: "S1",
438
+ phase: "观察段",
439
+ title: "技能:alpha",
440
+ when: "(待补)这一段的入口",
441
+ do: "调用技能 alpha",
442
+ who: "main",
443
+ tool: "skill",
444
+ done: "(待补)这一步怎么算完成",
445
+ risk: "R0",
446
+ needs: [],
447
+ why: "(待补)为什么必须有这一步",
448
+ evidence: []
449
+ }
450
+ ]
451
+ };
452
+ const first = writeDraftPipeline({ projectRoot, pipeline: draft });
453
+ assert.equal(first.ok, true);
454
+ assert.equal(first.file, join(projectPipelineDir(projectRoot), "nudge-deadbeef.json"));
455
+ // 写下去的东西必须能被读侧原样读回来——这是「产出的是工作流文件」而不是「产出的是 JSON」。
456
+ const back = loadPipelines(projectPipelineDir(projectRoot), { scope: "project", label: "proj" });
457
+ assert.equal(back.length, 1);
458
+ assert.equal(back[0].id, "nudge-deadbeef");
459
+ assert.equal(back[0].status, "draft");
460
+
461
+ const again = writeDraftPipeline({ projectRoot, pipeline: draft });
462
+ assert.equal(again.ok, false);
463
+ assert.equal(again.reason, "exists", "已经在人手里的文件不能被机器改写");
464
+ // 升格不在这条链上:沉淀链只产草稿。
465
+ const active = writeDraftPipeline({ projectRoot, pipeline: { ...draft, id: "nudge-active", status: "active" } });
466
+ assert.equal(active.ok, false);
467
+ assert.equal(active.reason, "not-draft");
468
+ const broken = writeDraftPipeline({ projectRoot, pipeline: { ...draft, id: "Bad_ID" } });
469
+ assert.equal(broken.ok, false);
470
+ assert.equal(broken.reason, "invalid");
471
+ });
472
+ });
473
+
474
+ test("提议落 memoryDir:按项目分桶、坏文件不静默跳过", () => {
475
+ withDir((dir) => {
476
+ const memoryDir = join(dir, "memory");
477
+ const projectRoot = join(dir, "proj");
478
+ const bucket = proposalDir({ memoryDir, projectRoot });
479
+ assert.ok(bucket.startsWith(memoryDir), "提议住 memoryDir,不进仓库");
480
+ assert.ok(bucket.includes("proposals"));
481
+ const proposal = {
482
+ id: "nudge-abc123",
483
+ origin: "nudge",
484
+ status: "proposed",
485
+ project: "proj",
486
+ names: ["skill:alpha", "skill:beta"],
487
+ steps: [{ id: "S1", title: "技能:alpha", evidence: [] }],
488
+ support: 2,
489
+ counterfactual: null
490
+ };
491
+ assert.deepEqual(validateProposal(proposal), { ok: true, problems: [] });
492
+ saveProposal(bucket, proposal);
493
+ assert.equal(existsSync(join(bucket, "nudge-abc123.json")), true);
494
+ assert.equal(loadProposals(bucket).length, 1);
495
+
496
+ // 缺了判据那一栏的候选不是候选。
497
+ const noCf = { ...proposal, id: "nudge-abc124" };
498
+ delete noCf.counterfactual;
499
+ assert.equal(validateProposal(noCf).ok, false);
500
+ assert.equal(validateProposal({ ...proposal, status: "collecting" }).ok, false);
501
+ assert.deepEqual(PROPOSAL_STATUSES, ["proposed", "answered", "drafted", "dismissed"]);
502
+
503
+ writeFileSync(join(bucket, "nudge-broken.json"), "{ not json");
504
+ assert.throws(() => loadProposals(bucket), /JSON 解析失败/, "坏文件要炸出来,不做「跳过继续」");
505
+ // 同一个项目算到同一个桶(分桶键不含绝对路径,也不含本机用户名)。
506
+ assert.equal(proposalDir({ memoryDir, projectRoot }), bucket);
507
+ });
508
+ });
509
+
510
+ test("清单行:反事实覆盖率与风险下限照实投影", () => {
511
+ const proposal = {
512
+ id: "nudge-abc123",
513
+ status: "proposed",
514
+ names: ["skill:a", "skill:b"],
515
+ support: 3,
516
+ firstSeenAt: "2026-10-01T00:00:00.000Z",
517
+ lastSeenAt: "2026-10-15T00:00:00.000Z",
518
+ steps: [
519
+ { id: "S1", title: "a", risk: "R0", evidence: ["人答:会失败"] },
520
+ { id: "S2", title: "b", risk: "R2", evidence: [] }
521
+ ],
522
+ counterfactual: { verdict: null }
523
+ };
524
+ const row = summarizeProposal(proposal);
525
+ // 19/19 与 6/19 是两条不同的手法,清单上就该看得出来。
526
+ assert.deepEqual(row.contrast, { answered: 1, total: 2 });
527
+ assert.equal(row.riskFloor, "R2", "整条的风险取下限里的最高档");
528
+ assert.equal(row.verdict, null, "判据没有结论时清单里也是 null,不显示一个假结论");
529
+ });
530
+
531
+ test("自精简提议走同一条「要人点头」的通路(必经步骤被跳 ≥3 次 → 修图+删步并存)", () => {
532
+ const pipeline = {
533
+ id: "demo",
534
+ steps: [{ id: "S1", title: "第一步" }, { id: "S2", title: "第二步" }]
535
+ };
536
+ const runs = [
537
+ { steps: [{ id: "S2", skipped: true }] },
538
+ { steps: [{ id: "S2", skipped: true }] },
539
+ { steps: [{ id: "S2", skipped: true }] },
540
+ { steps: [{ id: "S1" }] }
541
+ ];
542
+ const proposal = retirementProposals(pipeline, { runs });
543
+ // R2 新语义:S2 是必经(无 optional)、从未 uses、被跳 3 次 → 删步与修图两种读法并存
544
+ assert.equal(proposal.items.length, 2);
545
+ const kinds = proposal.items.map((x) => x.kind).sort();
546
+ assert.deepEqual(kinds, ["drop-step", "rework-path"]);
547
+ const drop = proposal.items.find((x) => x.kind === "drop-step");
548
+ assert.equal(drop.stepId, "S2");
549
+ assert.match(drop.because, /被跳过 3 次/);
550
+ for (const item of proposal.items) {
551
+ assert.ok(item.question.length > 0, "提议不发问就只是通知,不叫要人点头");
552
+ assert.ok(item.choices.includes("不知道"));
553
+ }
554
+ // 记了 4 次 run 但只有 1 次真走过 S1:计数只数处置,不数次数。
555
+ assert.equal(retirementProposals(pipeline, { runs }).items.length, 2);
556
+ });
557
+
558
+ test("rework-path 按 cause 分流问法:skipped 问删步/改 optional,detoured 问重画路径", () => {
559
+ // 锁的是 CodeRabbit #458 F3:同 kind 两种成因(pipeline.js 的 skipped/detoured
560
+ // 两个触发点)若共用一句问法,跳过场景的人会被问「岔路」——答非所问。
561
+ const pipeline = { id: "demo", steps: [{ id: "S1", title: "第一步" }, { id: "D1", title: "岔口" }] };
562
+ // S1:必经、从未 uses、被跳 3 次 → rework-path(cause=skipped)
563
+ // D1:被绕开 3 次 → rework-path(cause=detoured)
564
+ const runs = [
565
+ { steps: [{ id: "S1", skipped: true }, { id: "D1", detoured: true }] },
566
+ { steps: [{ id: "S1", skipped: true }, { id: "D1", detoured: true }] },
567
+ { steps: [{ id: "S1", skipped: true }, { id: "D1", detoured: true }] }
568
+ ];
569
+ const items = retirementProposals(pipeline, { runs }).items.filter((x) => x.kind === "rework-path");
570
+ assert.equal(items.length, 2);
571
+ const byCause = Object.fromEntries(items.map((x) => [x.cause, x]));
572
+ assert.deepEqual(items.map((x) => x.cause).sort(), ["detoured", "skipped"], "cause 字段必须区分两种成因");
573
+ assert.match(byCause.skipped.question, /optional|流程/);
574
+ assert.ok(!/岔/.test(byCause.skipped.question), "跳过场景不该被问岔路的事");
575
+ assert.match(byCause.detoured.question, /岔/);
576
+ for (const item of items) {
577
+ assert.ok(item.choices.includes("不知道"));
578
+ }
579
+ });
580
+
581
+ test("端到端:真实形状的会话日志 → 至少一条候选 → 落一个过校验的 draft 文件", () => {
582
+ withDir((dir) => {
583
+ const sessions = join(dir, "sessions");
584
+ const memoryDir = join(dir, "memory");
585
+ const projectRoot = join(dir, "proj");
586
+ mkdirSync(projectRoot, { recursive: true });
587
+ // 两个会话走了同一段三步序列;第三个会话少了中间那一步(缺席对照的来源)。
588
+ for (const id of ["sess-a", "sess-b"]) {
589
+ writeSession(sessions, id, [
590
+ [sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), pwsh("npm test")] })]
591
+ ]);
592
+ }
593
+ writeSession(sessions, "sess-c", [[sessionLine({ turn: 1, calls: [skill("alpha"), pwsh("npm test")] })]]);
594
+
595
+ const doc = nudgeFromSessions({ sessionDir: sessions, projectRoot, memoryDir, project: "slow-stack/mneme", materialize: 1 });
596
+ assert.equal(doc.contract, "mneme.nudge/v1");
597
+ assert.equal(doc.source.present, true);
598
+ assert.equal(doc.source.scannedFiles, 3);
599
+ assert.ok(doc.counts.candidates >= 1, "真实形状的数据里必须挖得出候选");
600
+ assert.equal(doc.counts.draftsWritten, 1);
601
+ assert.equal(doc.drafts[0].ok, true);
602
+ assert.deepEqual(doc.kinds, ["skill", "mcp"], "挖的是哪一层要跟着结果一起报出来");
603
+ assert.equal(doc.boundaries.autoActivate, false);
604
+ assert.equal(doc.boundaries.autoAnswerCounterfactual, false);
605
+
606
+ // 草稿真的在项目里、真的能被读侧读回来、真的是 draft。
607
+ const dir2 = projectPipelineDir(projectRoot);
608
+ const files = readFileSync(join(dir2, `${doc.drafts[0].id}.json`), "utf8");
609
+ const parsed = JSON.parse(files);
610
+ assert.equal(parsed.status, "draft");
611
+ const back = loadPipelines(dir2, { scope: "project", label: "proj" });
612
+ assert.equal(back.length, 1);
613
+ assert.equal(back[0].status, "draft");
614
+ assert.ok(back[0].steps.length >= 2);
615
+ for (const step of back[0].steps) assert.deepEqual(step.evidence, []);
616
+
617
+ // 提议落进了 memoryDir 的项目桶,且带着「没人回答判据」的原话。
618
+ const proposals = loadProposals(doc.proposalsDir);
619
+ assert.equal(proposals.length, doc.counts.candidates);
620
+ const first = proposals.find((p) => p.id === doc.drafts[0].id);
621
+ assert.equal(first.counterfactual.verdict, null);
622
+ assert.ok(first.counterfactual.unobservable.length >= 1);
623
+
624
+ // 草稿文件里不许出现本机路径与用户名(它是要提交、要 review 的)。
625
+ assert.ok(!/[A-Za-z]:[\\/]/.test(files), "仓库文件里不许出现盘符路径");
626
+ const homeName = basename(homedir());
627
+ assert.equal(files.includes(homeName), false, "仓库文件里不许出现本机用户名");
628
+ // 临时目录的绝对路径同样不许进文件——命令原文里的路径会被 safeCommandTokens 洗掉。
629
+ assert.equal(files.includes(dir), false);
630
+ });
631
+ });
632
+
633
+ test("端到端:会话目录空时如实报空,不伪造候选、也不落草稿", () => {
634
+ withDir((dir) => {
635
+ const sessions = join(dir, "sessions");
636
+ mkdirSync(sessions, { recursive: true });
637
+ const projectRoot = join(dir, "proj");
638
+ mkdirSync(projectRoot, { recursive: true });
639
+ const doc = nudgeFromSessions({ sessionDir: sessions, projectRoot, memoryDir: join(dir, "memory"), materialize: true });
640
+ assert.equal(doc.counts.candidates, 0);
641
+ assert.equal(doc.counts.draftsWritten, 0);
642
+ assert.deepEqual(doc.drafts, []);
643
+ assert.equal(existsSync(projectPipelineDir(projectRoot)), false, "没有候选就不该建出 pipelines 目录");
644
+ });
645
+ });
646
+
647
+ test("会话根目录跟着 DSH_HOME 走,不写死本机路径", () => {
648
+ const fake = join(tmpdir(), "mneme-dsh-home");
649
+ assert.equal(resolveSessionDir({ dshHome: fake }), join(fake, "sessions"));
650
+ // 显式传 dshHome 优先于环境变量;两者都没有时落在 ~/.dsh 下(不出现盘符字面量)。
651
+ const fakeHome = ["D:", "somewhere", ".dsh"].join("/");
652
+ assert.equal(resolveSessionDir({ dshHome: fakeHome }), join(fakeHome, "sessions"));
653
+ });
654
+
655
+ test("forEachZstdFrame:坏帧计入 failed 而不静默吞掉", () => {
656
+ const good = zlib.zstdCompressSync(Buffer.from("hello"));
657
+ const bad = Buffer.concat([Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), Buffer.from("nonsense")]);
658
+ const seen = [];
659
+ const stat = forEachZstdFrame(Buffer.concat([good, bad]), (text) => seen.push(text));
660
+ assert.deepEqual(seen, ["hello"]);
661
+ assert.equal(stat.frames, 1);
662
+ assert.equal(stat.failed, 1, "解不出来的帧要被计数,不能当成「本来就没有内容」");
663
+ });
664
+
665
+ test("写盘留下的目录结构不改动已有工作流(不碰发布工作流那条)", () => {
666
+ withDir((dir) => {
667
+ const projectRoot = join(dir, "proj");
668
+ const dir2 = projectPipelineDir(projectRoot);
669
+ mkdirSync(dir2, { recursive: true });
670
+ const human = { id: "handwritten", note: "人写的" };
671
+ writeFileSync(join(dir2, "handwritten.json"), JSON.stringify(human));
672
+ const before = statSync(join(dir2, "handwritten.json")).mtimeMs;
673
+ const made = writeDraftPipeline({
674
+ projectRoot,
675
+ pipeline: {
676
+ id: "nudge-keep",
677
+ name: "x",
678
+ project: "p",
679
+ version: 0,
680
+ status: "draft",
681
+ done: "d",
682
+ trigger: [{ kind: "phrase", value: "t" }],
683
+ steps: [
684
+ {
685
+ id: "S1",
686
+ phase: "观察段",
687
+ title: "t",
688
+ when: "w",
689
+ do: "do",
690
+ who: "main",
691
+ tool: "read",
692
+ done: "d",
693
+ risk: "R0",
694
+ needs: [],
695
+ why: "y",
696
+ evidence: []
697
+ }
698
+ ]
699
+ }
700
+ });
701
+ assert.equal(made.ok, true);
702
+ assert.equal(statSync(join(dir2, "handwritten.json")).mtimeMs, before, "人写的文件不动");
703
+ assert.deepEqual(readFileSync(join(dir2, "handwritten.json"), "utf8"), JSON.stringify(human));
704
+ });
705
+ });
706
+
707
+ // ─────────────────────────────────────────────────────────────────────────────
708
+ // 专家团复核(2026-10-16)抓出的阻断项,每条一个失败即红的回归用例。
709
+ // 这些用例的写法是「先按旧行为写会红」:比如空回答那条,旧实现返回 ok:true +
710
+ // status:"answered",这里的断言立刻炸。
711
+ // ─────────────────────────────────────────────────────────────────────────────
712
+
713
+ test("回归·空回答不算「已答」:一条都没接受就不许推进状态", () => {
714
+ const candidate = { id: "nudge-abc123", steps: [{ id: "S1", title: "技能:alpha", evidence: [] }] };
715
+ // 旧实现:{} 与 null 都返回 ok:true / status:"answered" / applied:0 —— 无人回答却已答。
716
+ for (const answers of [{}, null, undefined]) {
717
+ const r = applyAnswers(candidate, answers);
718
+ assert.equal(r.ok, false, `answers=${JSON.stringify(answers)} 必须失败`);
719
+ assert.equal(r.candidate, null, "不许交出一份「已答」的候选");
720
+ assert.equal(r.applied.length, 0);
721
+ assert.match(r.problems[0], /空回答不算/);
722
+ }
723
+ // 非法选项同样是失败(原有行为,别在修这条时弄丢)。
724
+ assert.equal(applyAnswers(candidate, { S1: "nope" }).ok, false);
725
+ // 真答了一条才推进。
726
+ const ok = applyAnswers(candidate, { S1: "would-fail" });
727
+ assert.equal(ok.ok, true);
728
+ assert.equal(ok.candidate.status, "answered");
729
+ assert.equal(ok.applied.length, 1);
730
+ });
731
+
732
+ test("回归·跨工作区不并账:两个桶各一次不是「支持度 2 的同一手法」", () => {
733
+ withDir((dir) => {
734
+ const sessions = join(dir, "sessions");
735
+ const seq = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]];
736
+ writeSession(join(sessions, "ws-a"), "sess-1", seq);
737
+ writeSession(join(sessions, "ws-b"), "sess-2", seq);
738
+ const cross = readSessionObservations({ sessionDir: sessions });
739
+ const crossSegments = segmentObservations(cross.observations);
740
+ assert.equal(crossSegments.length, 2);
741
+ assert.deepEqual(
742
+ mineRepeatedPatterns(crossSegments),
743
+ [],
744
+ "旧实现把两个项目的各一次并成支持度 2 —— 那是把两本账合成一本"
745
+ );
746
+
747
+ // 同一个桶里两个会话才够格,且候选带着自己的桶。
748
+ writeSession(join(sessions, "ws-a"), "sess-3", seq);
749
+ const read = readSessionObservations({ sessionDir: sessions });
750
+ const candidates = mineRepeatedPatterns(segmentObservations(read.observations));
751
+ assert.equal(candidates.length, 1);
752
+ assert.equal(candidates[0].support, 2);
753
+ assert.equal(candidates[0].bucket, "ws-a");
754
+ });
755
+ });
756
+
757
+ test("回归·扫全部会话时不许声称候选属于某个项目(拒绝归属,不写项目桶、不落草稿)", () => {
758
+ withDir((dir) => {
759
+ const sessions = join(dir, "sessions");
760
+ const seq = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]];
761
+ for (const id of ["sess-a", "sess-b"]) writeSession(sessions, id, seq);
762
+ const memoryDir = join(dir, "memory");
763
+ const projectRoot = join(dir, "proj");
764
+ mkdirSync(projectRoot, { recursive: true });
765
+
766
+ // 给 projectRoot、不给 sessionDir(=扫全部)→ 旧实现照旧贴项目名并写项目桶。
767
+ const loose = nudgeFromSessions({ dshHome: dir, projectRoot, memoryDir, materialize: true });
768
+ assert.ok(loose.counts.candidates >= 1, "普查本身照跑,候选还是要挖出来");
769
+ assert.equal(loose.refused.reason, "unscoped-sessions", "必须显式报出降级原因");
770
+ assert.equal(loose.project, null);
771
+ assert.equal(loose.proposalsDir, null, "不写任何项目的提议桶");
772
+ assert.equal(loose.counts.savedProposals, 0);
773
+ assert.equal(loose.counts.draftsWritten, 0, "更不许往项目里落草稿");
774
+ assert.equal(existsSync(memoryDir), false, "memoryDir 下连目录都不该建出来");
775
+ assert.equal(existsSync(join(projectRoot, ".mneme")), false);
776
+
777
+ // 给了该项目自己的 sessionDir 才认归属。
778
+ const scoped = nudgeFromSessions({ sessionDir: sessions, projectRoot, memoryDir, project: "slow-stack/mneme", materialize: 1 });
779
+ assert.equal(scoped.refused, null);
780
+ assert.equal(scoped.project, "slow-stack/mneme");
781
+ assert.ok(scoped.proposalsDir.includes("proposals"));
782
+ assert.equal(scoped.counts.savedProposals, scoped.counts.candidates);
783
+ assert.equal(scoped.counts.draftsWritten, 1);
784
+ });
785
+ });
786
+
787
+ test("回归·unanswered 补回出口,且与写盘文件里 evidence 为空的步骤一致", () => {
788
+ withDir((dir) => {
789
+ const sessions = join(dir, "sessions");
790
+ const memoryDir = join(dir, "memory");
791
+ const projectRoot = join(dir, "proj");
792
+ mkdirSync(projectRoot, { recursive: true });
793
+ const seq = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]];
794
+ for (const id of ["sess-a", "sess-b"]) writeSession(sessions, id, seq);
795
+
796
+ const doc = nudgeFromSessions({ sessionDir: sessions, projectRoot, memoryDir, project: "slow-stack/mneme", materialize: 1 });
797
+ assert.equal(doc.drafts.length, 1);
798
+ const draft = doc.drafts[0];
799
+ assert.equal(draft.ok, true);
800
+ assert.ok(Array.isArray(draft.unanswered), "旧实现把 draftFromCandidate 算出的 unanswered 丢在出口");
801
+ const saved = JSON.parse(readFileSync(draft.file, "utf8"));
802
+ assert.deepEqual(
803
+ draft.unanswered,
804
+ saved.steps.filter((s) => s.evidence.length === 0).map((s) => s.id),
805
+ "出口的 unanswered 必须就是写盘文件里还没人回答的那几步"
806
+ );
807
+ assert.equal(draft.unanswered.length, saved.steps.length, "没人回答时每一步都还没过准入判据");
808
+ // 失败分支也要带这个键(否则调用方要按分支判字段在不在)。
809
+ const broken = draftFromCandidate({ id: "nudge-x", steps: [] }, {});
810
+ assert.equal(broken.ok, false);
811
+ assert.ok(Array.isArray(broken.unanswered));
812
+ });
813
+ });
814
+
815
+ test("回归·原子写:rename 之前崩掉不留半个目标文件,正常路径不留临时文件", () => {
816
+ withDir((dir) => {
817
+ const file = join(dir, "x.json");
818
+ writeFileSync(file, "旧内容");
819
+ // 只断言「出现过 tmp 字样」证明不了这条修复;这里把「rename 之前崩掉」真模拟一次。
820
+ assert.throws(() => writeFileAtomic(file, "新内容", { beforeRename: () => { throw new Error("模拟崩溃"); } }), /模拟崩溃/);
821
+ assert.equal(readFileSync(file, "utf8"), "旧内容", "目标文件一个字节都不许动");
822
+ assert.deepEqual(readdirSync(dir), ["x.json"], "临时文件要收掉");
823
+ writeFileAtomic(file, "新内容");
824
+ assert.equal(readFileSync(file, "utf8"), "新内容");
825
+ assert.deepEqual(readdirSync(dir), ["x.json"]);
826
+ });
827
+ });
828
+
829
+ test("回归·落盘产物里没有半截文件:读侧整目录校验不能因为临时文件挂掉", () => {
830
+ withDir((dir) => {
831
+ const projectRoot = join(dir, "proj");
832
+ const memoryDir = join(dir, "memory");
833
+ const pipelineDir = projectPipelineDir(projectRoot);
834
+ mkdirSync(pipelineDir, { recursive: true });
835
+ // 先放一条人手写的合法工作流:它必须一直是可读的(读侧任一条坏就整层抛,
836
+ // 所以一个半截的机器文件能把人写的那条一起带没)。
837
+ const human = {
838
+ id: "handwritten",
839
+ name: "人写的",
840
+ project: "p",
841
+ version: 0,
842
+ status: "active",
843
+ done: "d",
844
+ trigger: [{ kind: "phrase", value: "t" }],
845
+ steps: [
846
+ { id: "A1", phase: "p", title: "t", when: "w", do: "d", who: "main", tool: "", done: "d", risk: "R0", needs: [], why: "y", evidence: [] }
847
+ ]
848
+ };
849
+ writeFileSync(join(pipelineDir, "handwritten.json"), `${JSON.stringify(human)}\n`);
850
+
851
+ const draft = {
852
+ id: "nudge-atomic",
853
+ name: "(待补)x",
854
+ project: "p",
855
+ version: 0,
856
+ status: "draft",
857
+ done: "d",
858
+ trigger: [{ kind: "phrase", value: "t" }],
859
+ steps: [
860
+ { id: "S1", phase: "观察段", title: "t", when: "w", do: "d", who: "main", tool: "", done: "d", risk: "R0", needs: [], why: "y", evidence: [] }
861
+ ]
862
+ };
863
+ const made = writeDraftPipeline({ projectRoot, pipeline: draft });
864
+ assert.equal(made.ok, true);
865
+ assert.deepEqual(readdirSync(pipelineDir).filter((f) => f.includes(".tmp-")), [], "temporaries 必须清零");
866
+ assert.equal(loadPipelines(pipelineDir, { scope: "project", label: "p" }).length, 2, "两条都读得出来");
867
+
868
+ // 提议那边同一套。
869
+ const proposal = {
870
+ id: "nudge-atomic",
871
+ origin: "nudge",
872
+ status: "proposed",
873
+ names: ["skill:a", "skill:b"],
874
+ steps: [{ id: "S1", title: "a", evidence: [] }],
875
+ support: 2,
876
+ counterfactual: null
877
+ };
878
+ const bucket = proposalDir({ memoryDir, projectRoot });
879
+ saveProposal(bucket, proposal);
880
+ assert.deepEqual(readdirSync(bucket).filter((f) => f.includes(".tmp-")), []);
881
+ assert.equal(loadProposals(bucket).length, 1);
882
+ });
883
+ });
884
+
885
+ test("回归·边界:校验层拒绝任何非 null 的 verdict(不只靠产者自觉)", () => {
886
+ const base = {
887
+ id: "nudge-abc123",
888
+ origin: "nudge",
889
+ status: "proposed",
890
+ names: ["skill:a", "skill:b"],
891
+ steps: [{ id: "S1", title: "a", evidence: [] }],
892
+ support: 2
893
+ };
894
+ const bad = { ...base, counterfactual: { verdict: "会漏步" } };
895
+ assert.equal(validateProposal(bad).ok, false);
896
+ assert.match(validateProposal(bad).problems[0], /verdict 必须是 null/);
897
+ // 缺 verdict 键 = undefined,也拒(否则「没写」会被读成「还没算」)。
898
+ assert.equal(validateProposal({ ...base, counterfactual: {} }).ok, false);
899
+ assert.equal(validateProposal({ ...base, counterfactual: { verdict: null } }).ok, true);
900
+ assert.equal(validateProposal({ ...base, counterfactual: null }).ok, true);
901
+ // 写盘时同样过不去:产者那条路绕不开这道边界。
902
+ withDir((dir) => {
903
+ assert.throws(() => saveProposal(join(dir, "p"), bad), /verdict 必须是 null/);
904
+ });
905
+ // 而 summarizeProposal 照旧原样投影 —— 正因为它会投影,才必须在校验层拦。
906
+ assert.equal(summarizeProposal({ ...base, counterfactual: { verdict: null } }).verdict, null);
907
+ });
908
+
909
+ test("回归·按层过滤后的相邻不许当真相邻:夹着的调用要留痕", () => {
910
+ const raw = [
911
+ {
912
+ sessionId: "s1",
913
+ bucket: "ws-a",
914
+ turn: 1,
915
+ steps: [
916
+ { key: "tool:read", kind: "tool", name: "read", tool: "read", command: "", repeats: 4 },
917
+ { key: "skill:alpha", kind: "skill", name: "alpha", tool: "skill", command: "", repeats: 1 },
918
+ { key: "tool:read", kind: "tool", name: "read", tool: "read", command: "", repeats: 3 },
919
+ { key: "tool:grep", kind: "tool", name: "grep", tool: "grep", command: "", repeats: 1 },
920
+ { key: "skill:beta", kind: "skill", name: "beta", tool: "skill", command: "", repeats: 1 }
921
+ ]
922
+ }
923
+ ];
924
+ const scoped = filterSegmentsByKinds(raw, ["skill", "mcp"]);
925
+ assert.deepEqual(scoped[0].steps.map((s) => s.name), ["alpha", "beta"]);
926
+ assert.deepEqual(scoped[0].steps[0].elided, { count: 4, tools: ["tool:read"], head: true }, "开头夹着的要记,且标明在它之前");
927
+ assert.deepEqual(scoped[0].steps[1].elided, { count: 4, tools: ["tool:read", "tool:grep"], head: false });
928
+
929
+ const [candidate] = mineRepeatedPatterns(raw, { minSupport: 1, kinds: ["skill", "mcp"] });
930
+ const steps = candidate.steps;
931
+ assert.match(steps[0].when, /它之前还有 4 次其他调用/, "开头不是「这一段的入口」");
932
+ assert.match(steps[1].when, /中间还有 4 次其他调用/);
933
+ assert.match(steps[1].when, /相邻关系是过滤后的/);
934
+ assert.ok(!/紧跟在/.test(steps[1].when), "原始观察里中间隔着 4 次调用,不许断言「紧跟在」");
935
+
936
+ // 真的相邻时措辞不变(否则这条修复就成了「一律不敢说」)。
937
+ const tight = [
938
+ {
939
+ sessionId: "s2",
940
+ bucket: "ws-a",
941
+ turn: 1,
942
+ steps: [
943
+ { key: "skill:alpha", kind: "skill", name: "alpha", tool: "skill", command: "", repeats: 1 },
944
+ { key: "skill:beta", kind: "skill", name: "beta", tool: "skill", command: "", repeats: 1 }
945
+ ]
946
+ }
947
+ ];
948
+ const [tightCandidate] = mineRepeatedPatterns(tight, { minSupport: 1, kinds: ["skill", "mcp"] });
949
+ assert.match(tightCandidate.steps[1].when, /紧跟在/);
950
+ assert.equal(tightCandidate.steps[1].elided, null);
951
+ });
952
+
953
+ test("回归·failures:未接线与「确实一条都没有」必须分开报", () => {
954
+ const candidate = {
955
+ id: "nudge-abc123",
956
+ bucket: null,
957
+ names: ["skill:a", "skill:b"],
958
+ steps: [{ id: "S1", title: "a", evidence: [] }],
959
+ support: 2,
960
+ occurrences: 2,
961
+ sessions: ["s1", "s2"],
962
+ turns: 2
963
+ };
964
+ const unwired = buildCounterfactual(candidate, { segments: [] });
965
+ assert.notEqual(unwired.observable.failures, null, "旧实现用 null 同时表示「没接线」和「没有记录」");
966
+ assert.equal(unwired.observable.failures.available, false);
967
+ assert.equal(unwired.observable.failures.reason, "not-wired");
968
+ const empty = buildCounterfactual(candidate, { segments: [], failures: [] });
969
+ assert.deepEqual(empty.observable.failures, { available: true, count: 0, kinds: [] });
970
+ const rows = buildCounterfactual(candidate, {
971
+ segments: [],
972
+ failures: [{ failure_type: "correction" }, { failure_type: "correction" }, { failure_type: "recall" }]
973
+ });
974
+ assert.deepEqual(rows.observable.failures, { available: true, count: 3, kinds: ["correction", "recall"] });
975
+
976
+ withDir((dir) => {
977
+ const sessions = join(dir, "sessions");
978
+ mkdirSync(sessions, { recursive: true });
979
+ const loose = nudgeFromSessions({ sessionDir: sessions, memoryDir: null, projectRoot: null });
980
+ assert.equal(loose.source.failureSource, "unwired");
981
+ const wired = nudgeFromSessions({ sessionDir: sessions, memoryDir: null, projectRoot: null, failures: [] });
982
+ assert.equal(wired.source.failureSource, "caller");
983
+ });
984
+ });
985
+
986
+ test("回归·长度为 2 的候选:对照栏显式留白,不静默变空", () => {
987
+ withDir((dir) => {
988
+ const sessions = join(dir, "sessions");
989
+ const seq = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]];
990
+ for (const id of ["sess-a", "sess-b"]) writeSession(sessions, id, seq);
991
+ const read = readSessionObservations({ sessionDir: sessions });
992
+ const segments = segmentObservations(read.observations);
993
+ const [candidate] = mineRepeatedPatterns(segments);
994
+ assert.equal(candidate.names.length, 2);
995
+ const contrast = omissionContrast(candidate, segments);
996
+ assert.deepEqual(contrast.steps, []);
997
+ assert.equal(contrast.empty, true, "「空」要说出来,否则看的人会把它读成「反例为零」");
998
+ assert.match(contrast.note, /留白/);
999
+ // 有中间步骤的候选不该带这条留白说明。长度 3 的候选要在**两个**会话里出现,
1000
+ // 否则它要么被支持度门槛筛掉,要么(只出现一次时)被极大模式并进那条两步的。
1001
+ const three = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), skill("gamma")] })]];
1002
+ writeSession(sessions, "sess-c", three);
1003
+ writeSession(sessions, "sess-d", three);
1004
+ const read2 = readSessionObservations({ sessionDir: sessions });
1005
+ const segs2 = segmentObservations(read2.observations);
1006
+ const longOne = mineRepeatedPatterns(segs2).find((c) => c.names.length === 3);
1007
+ assert.ok(longOne, "三步序列在两个会话里出现过,必须挖得出来");
1008
+ const contrast2 = omissionContrast(longOne, segs2);
1009
+ assert.equal(contrast2.empty, false);
1010
+ assert.equal(contrast2.note, "");
1011
+ assert.equal(contrast2.steps.length, 1, "只有中间那一步有缺席对照");
1012
+ });
1013
+ });
1014
+
1015
+ test("回归·文件上限按排序截断,不按遍历顺序(选择偏差要能被看见)", () => {
1016
+ withDir((dir) => {
1017
+ const sessions = join(dir, "sessions");
1018
+ // 故意让「造出来的顺序」与「字典序」相反。
1019
+ writeSession(join(sessions, "z-ws"), "sess-1", [[sessionLine({ turn: 1, calls: [skill("alpha")] })]]);
1020
+ writeSession(join(sessions, "a-ws"), "sess-2", [[sessionLine({ turn: 1, calls: [skill("beta")] })]]);
1021
+ writeSession(join(sessions, "m-ws"), "sess-3", [[sessionLine({ turn: 1, calls: [skill("gamma")] })]]);
1022
+ const found = findSessionFiles(sessions, { limit: 2 });
1023
+ assert.equal(found.totalFiles, 3, "真总数要能看见");
1024
+ assert.equal(found.files.length, 2);
1025
+ assert.equal(found.truncated, true);
1026
+ assert.ok(
1027
+ found.files.every((f) => f.includes("a-ws") || f.includes("m-ws")),
1028
+ "留下的是字典序靠前的两个,而不是遍历时先撞上的那两个"
1029
+ );
1030
+ // 读数层要把总数与截断一起透出去。
1031
+ const read = readSessionObservations({ sessionDir: sessions, limit: 2 });
1032
+ assert.equal(read.totalFiles, 3);
1033
+ assert.equal(read.truncated, true);
1034
+ assert.equal(read.scannedFiles, 2);
1035
+ });
1036
+ });