@modusensus/dsh-mneme 0.9.0 → 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 (88) hide show
  1. package/README.md +78 -16
  2. package/bin/cli.mjs +8 -2
  3. package/docs/CONFIGURATION.md +25 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +282 -57
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +598 -248
  9. package/lib/config.js +33 -2
  10. package/lib/embedding.js +116 -15
  11. package/lib/index.js +32 -0
  12. package/lib/inject.js +34 -3
  13. package/lib/lang.js +124 -0
  14. package/lib/pipeline-activities.js +178 -0
  15. package/lib/pipeline-attribution.js +165 -0
  16. package/lib/pipeline-command.js +279 -0
  17. package/lib/pipeline-confirm.js +142 -0
  18. package/lib/pipeline-exec.js +174 -0
  19. package/lib/pipeline-nudge.js +1281 -0
  20. package/lib/pipeline-observations.js +181 -0
  21. package/lib/pipeline-observe.js +133 -0
  22. package/lib/pipeline-runner.js +346 -0
  23. package/lib/pipeline-runs.js +442 -0
  24. package/lib/pipeline.js +128 -7
  25. package/lib/semantic.js +66 -10
  26. package/lib/settings.js +132 -1
  27. package/lib/store.js +77 -3
  28. package/lib/summarize.js +2 -3
  29. package/package.json +3 -3
  30. package/scripts/changelog-prep.mjs +62 -7
  31. package/scripts/check-sync.js +8 -6
  32. package/scripts/release-docs.mjs +204 -0
  33. package/scripts/release-notes.mjs +162 -0
  34. package/src/api-standalone.js +78 -12
  35. package/src/api.js +282 -57
  36. package/src/candidates.js +454 -454
  37. package/src/config.js +33 -2
  38. package/src/embedding.js +116 -15
  39. package/src/index.js +32 -0
  40. package/src/inject.js +34 -3
  41. package/src/lang.js +124 -0
  42. package/src/pipeline-activities.js +178 -0
  43. package/src/pipeline-attribution.js +165 -0
  44. package/src/pipeline-command.js +279 -0
  45. package/src/pipeline-confirm.js +142 -0
  46. package/src/pipeline-exec.js +174 -0
  47. package/src/pipeline-nudge.js +1281 -0
  48. package/src/pipeline-observations.js +181 -0
  49. package/src/pipeline-observe.js +133 -0
  50. package/src/pipeline-runner.js +346 -0
  51. package/src/pipeline-runs.js +442 -0
  52. package/src/pipeline.js +128 -7
  53. package/src/semantic.js +66 -10
  54. package/src/settings.js +132 -1
  55. package/src/store.js +77 -3
  56. package/src/summarize.js +2 -3
  57. package/test/api-hardening.test.js +173 -0
  58. package/test/api.test.js +98 -1
  59. package/test/changelog-prep.test.js +74 -8
  60. package/test/cli-enums.test.js +37 -0
  61. package/test/client.test.js +152 -3
  62. package/test/docs-config-keys.test.js +65 -0
  63. package/test/helpers/write-admission-samples.js +6 -2
  64. package/test/lib-smoke.test.js +20 -0
  65. package/test/persona-api.test.js +172 -0
  66. package/test/persona-flag.test.js +36 -0
  67. package/test/persona-inject.test.js +145 -0
  68. package/test/persona-settings.test.js +145 -0
  69. package/test/pipeline-activities.test.js +155 -0
  70. package/test/pipeline-attribution.test.js +175 -0
  71. package/test/pipeline-command.test.js +243 -0
  72. package/test/pipeline-confirm.test.js +148 -0
  73. package/test/pipeline-exec.test.js +172 -0
  74. package/test/pipeline-nudge.test.js +1036 -0
  75. package/test/pipeline-observations.test.js +157 -0
  76. package/test/pipeline-observe.test.js +127 -0
  77. package/test/pipeline-r1r2.test.js +149 -0
  78. package/test/pipeline-runner.test.js +442 -0
  79. package/test/pipeline-runs.test.js +767 -0
  80. package/test/pipeline.test.js +137 -6
  81. package/test/reindex-backfill.test.js +83 -4
  82. package/test/release-docs.test.js +130 -0
  83. package/test/release-notes.test.js +240 -0
  84. package/test/release-workflow.test.js +16 -0
  85. package/test/route-policy.test.js +113 -0
  86. package/test/security-doc.test.js +139 -0
  87. package/test/sensitive-scan.test.js +44 -0
  88. package/test/workflow-guards.test.js +156 -0
@@ -0,0 +1,442 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { tmpdir } from "node:os";
4
+ import { writeFileSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { createExecutionRecorder, projectRootOf, MAX_BUFFERED_EVENTS } from "../src/pipeline-runner.js";
7
+ import { validatePipeline } from "../src/pipeline.js";
8
+
9
+ // 这组测试守的是串联层:把「事件 → 观察 → 归属 → 窗口 → 落盘」串起来的那一层。
10
+ // 它最要紧的两条性质:
11
+ // ① 没开窗时**一个字都不记**(漏记可接受,编造不可)
12
+ // ② 热路径上**永不抛**(宿主的一步不许被插件的可选能力带崩)
13
+
14
+ const PIPE = validatePipeline({
15
+ id: "demo",
16
+ name: "演示",
17
+ project: "a/b",
18
+ version: 0,
19
+ status: "active",
20
+ done: "走完",
21
+ trigger: [{ kind: "phrase", value: "走一遍" }],
22
+ steps: [
23
+ { id: "P6", phase: "准备", title: "跑测试", when: "改完", do: "npm test", who: "main", tool: "npm test", done: "全绿", risk: "R0", needs: [], why: "因为" }
24
+ ]
25
+ }, "demo.json");
26
+
27
+ /** 造一个假的 ctx:只实现 on(),并记下 listener 供测试手动触发。 */
28
+ function fakeCtx() {
29
+ let listener = null;
30
+ return {
31
+ ctx: {
32
+ on(event, fn) {
33
+ if (event === "session/event") listener = fn;
34
+ return () => { listener = null; };
35
+ }
36
+ },
37
+ emit(session, event) { if (listener) listener(session, event); },
38
+ hasListener: () => listener !== null
39
+ };
40
+ }
41
+
42
+ function clockFrom(startMs) {
43
+ let n = 0;
44
+ return () => new Date(startMs + n++ * 1000);
45
+ }
46
+
47
+ function callAndResult(command, ok = true, callId = "c1") {
48
+ return [
49
+ { type: "tool/call", data: { callId, name: "pwsh", arguments: JSON.stringify({ command }) } },
50
+ { type: "tool/result", data: { callId, message: { isError: ok === false } } }
51
+ ];
52
+ }
53
+
54
+ test("没开窗时一个字都不记:事件到了也不缓冲(这是整个设计的地基)", () => {
55
+ const f = fakeCtx();
56
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
57
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
58
+
59
+ for (const e of callAndResult("npm test")) f.emit(session, e);
60
+ assert.deepEqual(rec.status({ session }), { open: false }, "没开窗就该没有窗口");
61
+ assert.equal(rec.stop({ session }).reason, "no-open-window");
62
+ });
63
+
64
+ test("开窗 → 走一遍 → 结账:产出并落盘一条账", () => {
65
+ const f = fakeCtx();
66
+ let written = null;
67
+ const rec = createExecutionRecorder({
68
+ ctx: f.ctx,
69
+ resolvePipeline: () => PIPE,
70
+ persistRun: (root, id, run) => { written = { root, id, run }; },
71
+ now: clockFrom(Date.UTC(2026, 9, 16, 12, 0, 0))
72
+ });
73
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
74
+
75
+ const started = rec.start({ session, pipelineId: "demo" });
76
+ assert.equal(started.ok, true);
77
+ assert.equal(started.steps, 1);
78
+
79
+ for (const e of callAndResult("npm test")) f.emit(session, e);
80
+
81
+ // P2 契约:stop **不再落盘**,只产出「请你确认」的提案
82
+ const stopped = rec.stop({ session });
83
+ assert.equal(stopped.ok, true);
84
+ assert.equal(stopped.state, "awaiting-confirmation");
85
+ assert.equal(stopped.observations, 1);
86
+ assert.equal(written, null, "没人确认之前,一个字节都不许落盘");
87
+
88
+ // 自动归属的结果只作提示(PIPE 里 P6 的 tool 正是 npm test,所以这里会有 hint)
89
+ assert.deepEqual(stopped.proposal.steps.map((s) => s.id), ["P6"]);
90
+
91
+ // 人确认之后才写账——这是唯一入口
92
+ const confirmed = rec.confirm({ session, answers: { P6: "done" } });
93
+ assert.equal(confirmed.ok, true);
94
+ assert.equal(confirmed.steps, 1);
95
+ assert.equal(written.id, "demo");
96
+ assert.equal(written.root, "C:\\proj");
97
+ assert.deepEqual(written.run.steps.map((s) => s.id), ["P6"]);
98
+ assert.equal(written.run.by, "human", "人确认的账,by 应当是人");
99
+ });
100
+
101
+ test("P2:hint 只是提示,不预选——人不回答就什么都不写", () => {
102
+ const f = fakeCtx();
103
+ let written = null;
104
+ const rec = createExecutionRecorder({
105
+ ctx: f.ctx,
106
+ resolvePipeline: () => PIPE,
107
+ persistRun: (root, id, run) => { written = { id, run }; }
108
+ });
109
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
110
+ rec.start({ session, pipelineId: "demo" });
111
+ for (const e of callAndResult("npm test")) f.emit(session, e);
112
+ rec.stop({ session });
113
+
114
+ // 人什么都不说(全没答)→ 不产 run,也不写盘
115
+ const r = rec.confirm({ session, answers: {} });
116
+ assert.equal(r.ok, false);
117
+ assert.equal(r.reason, "no-answers");
118
+ assert.deepEqual(r.unanswered, ["P6"]);
119
+ assert.equal(written, null, "没回答 = 没确认 = 不写");
120
+ });
121
+
122
+ test("P2:unsure 与 notDone 在落盘结果里必须分得开", () => {
123
+ const f = fakeCtx();
124
+ let written = null;
125
+ const rec = createExecutionRecorder({
126
+ ctx: f.ctx,
127
+ resolvePipeline: () => PIPE,
128
+ persistRun: (root, id, run) => { written = { id, run }; }
129
+ });
130
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
131
+ rec.start({ session, pipelineId: "demo" });
132
+ rec.stop({ session });
133
+
134
+ const r = rec.confirm({ session, answers: { P6: "unsure" } });
135
+ // 全 unsure → 无记录可写
136
+ assert.equal(r.ok, false);
137
+ assert.equal(r.reason, "no-answers");
138
+ assert.deepEqual(r.unsure, ["P6"]);
139
+ assert.equal(written, null);
140
+ });
141
+
142
+ test("P2:非法回答保留待确认状态,让人重答(不丢掉、不静默少记)", () => {
143
+ const f = fakeCtx();
144
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
145
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
146
+ rec.start({ session, pipelineId: "demo" });
147
+ rec.stop({ session });
148
+
149
+ const bad = rec.confirm({ session, answers: { P6: "yes" } });
150
+ assert.equal(bad.reason, "bad-answer-value");
151
+ // 状态还在,可以重答
152
+ assert.equal(rec.status({ session }).awaitingConfirmation, true);
153
+ const ok = rec.confirm({ session, answers: { P6: "done" } });
154
+ assert.equal(ok.ok, true);
155
+ });
156
+
157
+ test("P2:没结账就确认 → 说清「没什么可确认的」", () => {
158
+ const f = fakeCtx();
159
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
160
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
161
+ rec.start({ session, pipelineId: "demo" });
162
+ assert.equal(rec.confirm({ session, answers: { P6: "done" } }).reason, "nothing-to-confirm");
163
+ });
164
+
165
+ test("P2:status 要能分辨「窗口开着」与「等你确认」两种状态", () => {
166
+ const f = fakeCtx();
167
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
168
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
169
+ rec.start({ session, pipelineId: "demo" });
170
+ assert.equal(rec.status({ session }).open, true);
171
+ assert.equal(rec.status({ session }).awaitingConfirmation, undefined);
172
+
173
+ rec.stop({ session });
174
+ const s = rec.status({ session });
175
+ assert.equal(s.open, false);
176
+ assert.equal(s.awaitingConfirmation, true);
177
+ assert.equal(s.pipelineId, "demo");
178
+ });
179
+
180
+ test("P2:结账时不再报「没认出来」为失败——认不出只是没 hint,人照样能确认", () => {
181
+ const f = fakeCtx();
182
+ let written = null;
183
+ const rec = createExecutionRecorder({
184
+ ctx: f.ctx,
185
+ resolvePipeline: () => PIPE,
186
+ persistRun: (root, id, run) => { written = { run }; }
187
+ });
188
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
189
+ rec.start({ session, pipelineId: "demo" });
190
+
191
+ // 只有无关命令:认不出任何步骤
192
+ f.emit(session, { type: "tool/call", data: { callId: "x", name: "read", arguments: '{"file_path":"a.md"}' } });
193
+ f.emit(session, { type: "tool/result", data: { callId: "x", message: { isError: false } } });
194
+ const stopped = rec.stop({ session });
195
+
196
+ // 这一步和 P1 的不同:认不出**不再是"失败"**,而是"等你自己说"
197
+ assert.equal(stopped.ok, true);
198
+ assert.equal(stopped.state, "awaiting-confirmation");
199
+ assert.equal(stopped.observations, 0);
200
+ assert.equal(stopped.notCommands, 1);
201
+ // 人来说"这一步走了",照样能记
202
+ const confirmed = rec.confirm({ session, answers: { P6: "done" } });
203
+ assert.equal(confirmed.ok, true);
204
+ assert.deepEqual(written.run.steps.map((s) => s.id), ["P6"]);
205
+ });
206
+
207
+ test("同会话重复开窗被拒,不顶掉前一个(顶掉会让前一次执行悄无声息地消失)", () => {
208
+ const f = fakeCtx();
209
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
210
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
211
+
212
+ assert.equal(rec.start({ session, pipelineId: "demo" }).ok, true);
213
+ const second = rec.start({ session, pipelineId: "demo" });
214
+ assert.equal(second.ok, false);
215
+ assert.equal(second.reason, "already-open");
216
+ });
217
+
218
+ test("不同会话各开各的窗口,互不干扰", () => {
219
+ const f = fakeCtx();
220
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
221
+ const a = { id: "sa", header: { cwd: "C:\\proj" } };
222
+ const b = { id: "sb", header: { cwd: "C:\\proj" } };
223
+
224
+ rec.start({ session: a, pipelineId: "demo" });
225
+ rec.start({ session: b, pipelineId: "demo" });
226
+ for (const e of callAndResult("npm test")) f.emit(a, e);
227
+
228
+ // 事件是**缓冲到 stop 才配对**的(结果可能比调用晚很久),所以开着窗时
229
+ // observations 本来就是 0——这里该验的是缓冲隔离,不是计数。
230
+ assert.equal(rec.status({ session: a }).bufferedEvents, 2, "a 的事件只进 a 的窗口");
231
+ assert.equal(rec.status({ session: b }).bufferedEvents, 0, "b 一条都不该收到");
232
+ });
233
+
234
+ test("找不到工作流时说清是「没有」还是「读不出来」,并给可用清单", () => {
235
+ const f = fakeCtx();
236
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => null, persistRun: () => {} });
237
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
238
+
239
+ const missing = rec.start({ session, pipelineId: "nope" });
240
+ assert.equal(missing.ok, false);
241
+ assert.equal(missing.reason, "pipeline-not-found");
242
+
243
+ const f2 = fakeCtx();
244
+ const rec2 = createExecutionRecorder({
245
+ ctx: f2.ctx,
246
+ resolvePipeline: () => { throw new Error("坏 JSON"); },
247
+ persistRun: () => {}
248
+ });
249
+ const broken = rec2.start({ session: { id: "s2", header: { cwd: "C:\\proj" } }, pipelineId: "demo" });
250
+ assert.equal(broken.reason, "pipeline-unreadable", "读不出来与没有这条是两件事");
251
+ assert.match(broken.detail, /坏 JSON/);
252
+ });
253
+
254
+ test("拿不到会话工作目录时拒开,而不是硬塞一个别处的项目", () => {
255
+ const f = fakeCtx();
256
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
257
+ const noCwd = { id: "s1", header: {} };
258
+ assert.equal(rec.start({ session: noCwd, pipelineId: "demo" }).reason, "no-project-root");
259
+ // 没有 session 也一样
260
+ assert.equal(rec.start({ pipelineId: "demo" }).reason, "no-session");
261
+ });
262
+
263
+ test("P2:一条观察都没认出来也不写账——但也不再当成失败(等人自己说)", () => {
264
+ const f = fakeCtx();
265
+ let wrote = false;
266
+ const rec = createExecutionRecorder({
267
+ ctx: f.ctx,
268
+ resolvePipeline: () => PIPE,
269
+ persistRun: () => { wrote = true; }
270
+ });
271
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
272
+ rec.start({ session, pipelineId: "demo" });
273
+
274
+ // 只有无关工具调用:认不出命令执行器,配不出观察
275
+ f.emit(session, { type: "tool/call", data: { callId: "x", name: "read", arguments: '{"file_path":"a.md"}' } });
276
+ const stopped = rec.stop({ session });
277
+
278
+ // 旧行为是 ok:false + reason:no-observations;P2 起认不出不再是"失败",
279
+ // 因为账本来就该由人来说,机器的认不出只是"没给 hint"。
280
+ assert.equal(stopped.ok, true);
281
+ assert.equal(stopped.state, "awaiting-confirmation");
282
+ assert.equal(stopped.observations, 0);
283
+ assert.equal(wrote, false, "仍然不写空账");
284
+ });
285
+
286
+ test("P2:写盘失败发生在 confirm 阶段,如实回报且不留悬着的待确认状态", () => {
287
+ const f = fakeCtx();
288
+ const rec = createExecutionRecorder({
289
+ ctx: f.ctx,
290
+ resolvePipeline: () => PIPE,
291
+ persistRun: () => { throw new Error("磁盘满"); }
292
+ });
293
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
294
+ rec.start({ session, pipelineId: "demo" });
295
+ for (const e of callAndResult("npm test")) f.emit(session, e);
296
+
297
+ // stop 阶段不该碰磁盘——它是"请你确认",不是"写账"
298
+ const stopped = rec.stop({ session });
299
+ assert.equal(stopped.ok, true);
300
+
301
+ const confirmed = rec.confirm({ session, answers: { P6: "done" } });
302
+ assert.equal(confirmed.ok, false);
303
+ assert.equal(confirmed.reason, "write-failed");
304
+ assert.match(confirmed.detail, /磁盘满/);
305
+ // 写失败后不该留一个悬着的待确认状态(否则人会以为还要再确认一次)
306
+ assert.equal(rec.status({ session }).awaitingConfirmation, undefined);
307
+ });
308
+
309
+ test("热路径永不抛:listener 内部出错不冒泡到宿主的一步", () => {
310
+ const f = fakeCtx();
311
+ const rec = createExecutionRecorder({
312
+ ctx: f.ctx,
313
+ resolvePipeline: () => PIPE,
314
+ persistRun: () => {},
315
+ logger: { warn() {} }
316
+ });
317
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
318
+ rec.start({ session, pipelineId: "demo" });
319
+
320
+ // 各种畸形事件都不该抛
321
+ for (const bad of [null, undefined, {}, { type: "tool/call" }, { type: "tool/call", data: null }]) {
322
+ f.emit(session, bad);
323
+ }
324
+ assert.equal(true, true, "走到这里没抛就算过");
325
+ });
326
+
327
+ test("缓冲封顶:忘了 stop 时标记 truncated,而不是无限吃内存", () => {
328
+ const f = fakeCtx();
329
+ const rec = createExecutionRecorder({
330
+ ctx: f.ctx,
331
+ resolvePipeline: () => PIPE,
332
+ persistRun: () => {},
333
+ maxBufferedEvents: 3
334
+ });
335
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
336
+ rec.start({ session, pipelineId: "demo" });
337
+
338
+ for (let i = 0; i < 5; i += 1) {
339
+ f.emit(session, { type: "tool/call", data: { callId: `c${i}`, name: "pwsh", arguments: '{"command":"npm test"}' } });
340
+ }
341
+ const s = rec.status({ session });
342
+ assert.equal(s.truncated, true);
343
+ assert.equal(s.bufferedEvents, 3);
344
+ });
345
+
346
+ test("dispose 摘掉监听,之后的窗口全清", () => {
347
+ const f = fakeCtx();
348
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
349
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
350
+ rec.start({ session, pipelineId: "demo" });
351
+ assert.equal(f.hasListener(), true);
352
+
353
+ rec.dispose();
354
+ assert.equal(f.hasListener(), false);
355
+ assert.equal(rec.status({ session }).open, false);
356
+ rec.dispose(); // 重复 dispose 不报错
357
+ });
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // P3(丙):确认时刻的观察旁挂 + tool 不符检出
361
+ // 设计见 mneme-research/mo-foundations/10 号;丙 = 旁挂文件,不动已发布的账本契约
362
+ // ---------------------------------------------------------------------------
363
+ test("P3:给了 memoryDir 时,confirm 把观察旁挂 + 报出 tool 不符", () => {
364
+ const f = fakeCtx();
365
+ let written = null;
366
+ const rec = createExecutionRecorder({
367
+ ctx: f.ctx,
368
+ resolvePipeline: () => PIPE,
369
+ persistRun: (root, id, run) => { written = { run }; },
370
+ memoryDir: "D:/mem-test" // 注入(真实现里来自 config.memoryDir)
371
+ });
372
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
373
+ rec.start({ session, pipelineId: "demo" });
374
+ // 实际敲的是 node --test,不是步骤声明的 npm test → 人确认后应报不符
375
+ f.emit(session, { type: "tool/call", data: { callId: "c1", name: "pwsh", arguments: '{"command":"node --test test/x.test.js"}' } });
376
+ f.emit(session, { type: "tool/result", data: { callId: "c1", message: { isError: false } } });
377
+ rec.stop({ session });
378
+ const confirmed = rec.confirm({ session, answers: { P6: "done" } });
379
+
380
+ assert.equal(confirmed.ok, true);
381
+ assert.equal(confirmed.toolMismatches.length, 1, "声明 npm test 未出现 → 一条不符");
382
+ assert.equal(confirmed.toolMismatches[0].declared, "npm test");
383
+ assert.equal(confirmed.observationsSaved.ok, true, "观察已旁挂");
384
+ assert.equal(confirmed.observationsSaved.lines, 1, "node --test 一条");
385
+ });
386
+
387
+ test("P3:没给 memoryDir 时静默关闭——旁挂 skipped,但 mismatch 照算(不落盘的那半不连累这半)", () => {
388
+ const f = fakeCtx();
389
+ const rec = createExecutionRecorder({ ctx: f.ctx, resolvePipeline: () => PIPE, persistRun: () => {} });
390
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
391
+ rec.start({ session, pipelineId: "demo" });
392
+ f.emit(session, { type: "tool/call", data: { callId: "c1", name: "pwsh", arguments: '{"command":"node --test x"}' } });
393
+ f.emit(session, { type: "tool/result", data: { callId: "c1", message: { isError: false } } });
394
+ rec.stop({ session });
395
+ const confirmed = rec.confirm({ session, answers: { P6: "done" } });
396
+
397
+ assert.equal(confirmed.ok, true);
398
+ assert.equal(confirmed.observationsSaved.skipped, true, "没配 memoryDir = P3 旁挂关");
399
+ assert.equal(confirmed.toolMismatches.length, 1, "检出与旁挂是两件事,各自独立");
400
+ });
401
+
402
+ test("P3:旁挂写失败不连累记账(账本才是唯一事实)", () => {
403
+ const f = fakeCtx();
404
+ let written = false;
405
+ // 占位文件:往「一个文件路径的下面」建目录必然 ENOTDIR(node mkdirSync 实测)
406
+ const blocker = join(tmpdir(), "mo-p3-blocker-" + Date.now());
407
+ writeFileSync(blocker, "x", "utf8");
408
+ const rec = createExecutionRecorder({
409
+ ctx: f.ctx,
410
+ resolvePipeline: () => PIPE,
411
+ persistRun: () => { written = true; },
412
+ memoryDir: blocker, // <memoryDir>/observations/... 的父级是个文件 → mkdir 抛
413
+ logger: { warn: () => {} }
414
+ });
415
+ try {
416
+ const session = { id: "s1", header: { cwd: "C:\\proj" } };
417
+ rec.start({ session, pipelineId: "demo" });
418
+ f.emit(session, { type: "tool/call", data: { callId: "c1", name: "pwsh", arguments: '{"command":"npm test"}' } });
419
+ f.emit(session, { type: "tool/result", data: { callId: "c1", message: { isError: false } } });
420
+ rec.stop({ session });
421
+ const confirmed = rec.confirm({ session, answers: { P6: "done" } });
422
+
423
+ assert.equal(confirmed.ok, true, "旁挂炸了,记账也必须成功");
424
+ assert.equal(written, true, "账本照写");
425
+ assert.equal(confirmed.observationsSaved.ok, false, "旁挂如实报未成");
426
+ assert.equal(confirmed.toolMismatches.length, 0, "声明 npm test 且观察到了它 → 无不符");
427
+ } finally {
428
+ rmSync(blocker, { force: true });
429
+ }
430
+ });
431
+
432
+ test("projectRootOf 与 scope.js 同口径:header 优先,不在时退回请求头", () => {
433
+ assert.equal(projectRootOf({ header: { cwd: "C:\\a" } }), "C:\\a");
434
+ assert.equal(projectRootOf({ requestHeader: () => ({ config: { cwd: "C:\\b" } }) }), "C:\\b");
435
+ assert.equal(projectRootOf({ header: {} }), null);
436
+ assert.equal(projectRootOf(null), null);
437
+ });
438
+
439
+ test("缓冲上限常量存在且是个正整数", () => {
440
+ assert.equal(Number.isInteger(MAX_BUFFERED_EVENTS), true);
441
+ assert.ok(MAX_BUFFERED_EVENTS > 0);
442
+ });