@modusensus/dsh-mneme 0.8.16 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +91 -16
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +8 -2
  4. package/docs/CONFIGURATION.md +27 -1
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/SEMANTIC.md +3 -3
  7. package/docs/WORKFLOWS.md +299 -0
  8. package/lib/api-standalone.js +78 -12
  9. package/lib/api.js +403 -62
  10. package/lib/candidates.js +454 -0
  11. package/lib/capabilities.js +819 -0
  12. package/lib/client.js +2294 -423
  13. package/lib/config.js +63 -6
  14. package/lib/continuity.js +35 -6
  15. package/lib/dream/decisions.js +17 -1
  16. package/lib/dream/sleep.js +10 -2
  17. package/lib/dream.js +5 -1
  18. package/lib/embedding.js +116 -15
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/index.js +81 -12
  21. package/lib/inject.js +40 -4
  22. package/lib/lang.js +124 -0
  23. package/lib/pipeline-activities.js +178 -0
  24. package/lib/pipeline-attribution.js +165 -0
  25. package/lib/pipeline-command.js +279 -0
  26. package/lib/pipeline-confirm.js +142 -0
  27. package/lib/pipeline-exec.js +174 -0
  28. package/lib/pipeline-nudge.js +1281 -0
  29. package/lib/pipeline-observations.js +181 -0
  30. package/lib/pipeline-observe.js +133 -0
  31. package/lib/pipeline-runner.js +346 -0
  32. package/lib/pipeline-runs.js +442 -0
  33. package/lib/pipeline.js +655 -0
  34. package/lib/scope.js +16 -4
  35. package/lib/semantic.js +66 -10
  36. package/lib/serve.js +1 -1
  37. package/lib/service.js +59 -18
  38. package/lib/session-usage.js +350 -0
  39. package/lib/settings.js +138 -1
  40. package/lib/store.js +132 -6
  41. package/lib/summarize.js +30 -0
  42. package/lib/tools.js +11 -2
  43. package/package.json +3 -3
  44. package/scripts/changelog-prep.mjs +62 -7
  45. package/scripts/check-sync.js +8 -6
  46. package/scripts/publish-gate.mjs +197 -0
  47. package/scripts/release-docs.mjs +204 -0
  48. package/scripts/release-notes.mjs +162 -0
  49. package/src/api-standalone.js +78 -12
  50. package/src/api.js +403 -62
  51. package/src/candidates.js +454 -0
  52. package/src/capabilities.js +819 -0
  53. package/src/config.js +63 -6
  54. package/src/continuity.js +35 -6
  55. package/src/dream/decisions.js +17 -1
  56. package/src/dream/sleep.js +10 -2
  57. package/src/dream.js +5 -1
  58. package/src/embedding.js +116 -15
  59. package/src/entities/extractor.js +4 -0
  60. package/src/index.js +81 -12
  61. package/src/inject.js +40 -4
  62. package/src/lang.js +124 -0
  63. package/src/pipeline-activities.js +178 -0
  64. package/src/pipeline-attribution.js +165 -0
  65. package/src/pipeline-command.js +279 -0
  66. package/src/pipeline-confirm.js +142 -0
  67. package/src/pipeline-exec.js +174 -0
  68. package/src/pipeline-nudge.js +1281 -0
  69. package/src/pipeline-observations.js +181 -0
  70. package/src/pipeline-observe.js +133 -0
  71. package/src/pipeline-runner.js +346 -0
  72. package/src/pipeline-runs.js +442 -0
  73. package/src/pipeline.js +655 -0
  74. package/src/scope.js +16 -4
  75. package/src/semantic.js +66 -10
  76. package/src/serve.js +1 -1
  77. package/src/service.js +59 -18
  78. package/src/session-usage.js +350 -0
  79. package/src/settings.js +138 -1
  80. package/src/store.js +132 -6
  81. package/src/summarize.js +30 -0
  82. package/src/tools.js +11 -2
  83. package/test/api-hardening.test.js +173 -0
  84. package/test/api.test.js +244 -7
  85. package/test/candidates.test.js +345 -0
  86. package/test/capabilities.test.js +577 -0
  87. package/test/changelog-prep.test.js +74 -8
  88. package/test/cli-enums.test.js +37 -0
  89. package/test/client.test.js +510 -2
  90. package/test/continuity.test.js +84 -0
  91. package/test/docs-config-keys.test.js +65 -0
  92. package/test/embed-ready-poll.test.js +94 -0
  93. package/test/entities.test.js +13 -2
  94. package/test/failure-query.test.js +147 -0
  95. package/test/failure-retention.test.js +105 -0
  96. package/test/graph-api.test.js +36 -2
  97. package/test/helpers/write-admission-samples.js +6 -2
  98. package/test/inject-recall.test.js +32 -4
  99. package/test/lib-smoke.test.js +20 -0
  100. package/test/llm-audit.test.js +92 -1
  101. package/test/mint-source.test.js +202 -0
  102. package/test/persona-api.test.js +172 -0
  103. package/test/persona-flag.test.js +36 -0
  104. package/test/persona-inject.test.js +145 -0
  105. package/test/persona-settings.test.js +145 -0
  106. package/test/pipeline-activities.test.js +155 -0
  107. package/test/pipeline-attribution.test.js +175 -0
  108. package/test/pipeline-command.test.js +243 -0
  109. package/test/pipeline-confirm.test.js +148 -0
  110. package/test/pipeline-exec.test.js +172 -0
  111. package/test/pipeline-nudge.test.js +1036 -0
  112. package/test/pipeline-observations.test.js +157 -0
  113. package/test/pipeline-observe.test.js +127 -0
  114. package/test/pipeline-r1r2.test.js +149 -0
  115. package/test/pipeline-runner.test.js +442 -0
  116. package/test/pipeline-runs.test.js +767 -0
  117. package/test/pipeline.test.js +636 -0
  118. package/test/placement.test.js +1 -1
  119. package/test/publish-gate.test.js +272 -0
  120. package/test/recall-retention.test.js +128 -0
  121. package/test/reindex-backfill.test.js +83 -4
  122. package/test/release-docs.test.js +130 -0
  123. package/test/release-notes.test.js +240 -0
  124. package/test/release-workflow.test.js +93 -0
  125. package/test/route-policy.test.js +113 -0
  126. package/test/scope-distill-stamp.test.js +135 -0
  127. package/test/security-doc.test.js +139 -0
  128. package/test/sensitive-scan.test.js +44 -0
  129. package/test/serve.test.js +1 -1
  130. package/test/session-usage.test.js +308 -0
  131. package/test/store-write-path-columns.test.js +191 -0
  132. package/test/summarize.test.js +176 -0
  133. package/test/supersede-state-tracking.test.js +4 -4
  134. package/test/tools.test.js +16 -1
  135. package/test/workflow-guards.test.js +156 -0
@@ -0,0 +1,243 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseMoRunInput, createMoRunHandler, registerMoRunCommand, MO_RUN_COMMAND } from "../src/pipeline-command.js";
4
+
5
+ // 这组测试守的是 `/mo-run` 的回执质量。命令回执是这个功能**唯一的对外窗口**
6
+ // (面板侧还没做),所以最要紧的性质是:**失败必须说清是哪种失败**,
7
+ // 尤其「一条观察都没认出来」绝不能被含糊成「完成」。
8
+
9
+ test("解析:三种子命令 + 默认看状态 + 不认识的词给用法", () => {
10
+ assert.deepEqual(parseMoRunInput("start dsh-mneme-release"), { action: "start", pipelineId: "dsh-mneme-release" });
11
+ assert.deepEqual(parseMoRunInput("stop"), { action: "stop" });
12
+ assert.deepEqual(parseMoRunInput("status"), { action: "status" });
13
+ // 不带参数 = 看状态:最安全的默认,不会误开窗
14
+ assert.deepEqual(parseMoRunInput(""), { action: "status" });
15
+ assert.deepEqual(parseMoRunInput(" "), { action: "status" });
16
+ assert.deepEqual(parseMoRunInput(null), { action: "status" });
17
+ assert.equal(parseMoRunInput("whatever").action, "help");
18
+ // 大小写不敏感,id 里带空格也能取全
19
+ assert.deepEqual(parseMoRunInput("START a b"), { action: "start", pipelineId: "a b" });
20
+ });
21
+
22
+ /** 造一个受控的假记录器,回执里只看它返回的东西怎么被翻译。 */
23
+ function fakeRecorder({ start, stop, status, confirm } = {}) {
24
+ return {
25
+ start: start ?? (() => ({ ok: true, pipelineId: "demo", steps: 3 })),
26
+ stop: stop ?? (() => ({ ok: true, pipelineId: "demo", steps: 2 })),
27
+ // P2 起 stop 不再落盘,默认返回"待确认"
28
+ status: status ?? (() => ({ open: false })),
29
+ confirm: confirm ?? (() => ({ ok: true, pipelineId: "demo", steps: 1, notDone: [], unsure: [], unanswered: [] }))
30
+ };
31
+ }
32
+
33
+ const invocation = (rawInput) => ({ rawInput, agent: { session: { id: "s1" } } });
34
+
35
+ test("start 成功:说清开了哪条、几步", () => {
36
+ const h = createMoRunHandler({ recorder: fakeRecorder(), language: () => "zh" });
37
+ const r = h(invocation("start demo"));
38
+ assert.equal(r.kind, "success");
39
+ assert.match(r.text, /已开窗/);
40
+ assert.match(r.text, /demo/);
41
+ assert.match(r.text, /3 步/);
42
+ });
43
+
44
+ test("start 已开着窗口:给的是「先 stop」,而不是静默顶掉", () => {
45
+ const h = createMoRunHandler({
46
+ recorder: fakeRecorder({ start: () => ({ ok: false, reason: "already-open", pipelineId: "old" }) }),
47
+ language: () => "zh"
48
+ });
49
+ const r = h(invocation("start demo"));
50
+ assert.match(r.text, /already|已经开着|旧|old|先/);
51
+ });
52
+
53
+ test("start 找不到工作流:把可用清单给出来(别让人猜)", () => {
54
+ const h = createMoRunHandler({
55
+ recorder: fakeRecorder({ start: () => ({ ok: false, reason: "pipeline-not-found", available: ["a", "b"] }) }),
56
+ language: () => "zh"
57
+ });
58
+ const r = h(invocation("start nope"));
59
+ assert.match(r.text, /a/);
60
+ assert.match(r.text, /b/);
61
+ });
62
+
63
+ test("start 读不出来与没有这条是两种话——不许合并成同一句", () => {
64
+ const unreadable = createMoRunHandler({
65
+ recorder: fakeRecorder({ start: () => ({ ok: false, reason: "pipeline-unreadable", detail: "坏 JSON" }) }),
66
+ language: () => "zh"
67
+ });
68
+ const notFound = createMoRunHandler({
69
+ recorder: fakeRecorder({ start: () => ({ ok: false, reason: "pipeline-not-found", available: [] }) }),
70
+ language: () => "zh"
71
+ });
72
+ assert.notEqual(unreadable(invocation("start demo")).text, notFound(invocation("start demo")).text);
73
+ assert.match(unreadable(invocation("start demo")).text, /坏 JSON/);
74
+ });
75
+
76
+ test("P2:stop 产出待确认清单,明说「还没有写账」并给出步骤表", () => {
77
+ const h = createMoRunHandler({
78
+ recorder: fakeRecorder({
79
+ stop: () => ({
80
+ ok: true, state: "awaiting-confirmation", notCommands: 5, truncated: false,
81
+ activities: [{ key: "npm test", count: 3, flags: [] }],
82
+ proposal: {
83
+ pipelineId: "demo",
84
+ steps: [
85
+ { id: "P6", title: "跑测试", declared: "npm test", hintCount: 2 },
86
+ { id: "V2", title: "推分支", declared: "git push", hintCount: 0 }
87
+ ]
88
+ }
89
+ })
90
+ }),
91
+ language: () => "zh"
92
+ });
93
+ const r = h(invocation("stop"));
94
+ assert.match(r.text, /还没有写账/, "必须明确说这一版不自动落盘");
95
+ assert.match(r.text, /P6/, "步骤表要给出来供人勾");
96
+ assert.match(r.text, /V2/);
97
+ assert.match(r.text, /confirm/, "要给出怎么写确认");
98
+ assert.match(r.text, /npm test ×3/, "观察到的动作清单也要给");
99
+ });
100
+
101
+ test("P2:confirm 成功 → 报步数,并把「没做」「不确定」「没提到」分开", () => {
102
+ const h = createMoRunHandler({
103
+ recorder: fakeRecorder({
104
+ confirm: () => ({ ok: true, pipelineId: "demo", steps: 1, notDone: ["V2"], unsure: [], unanswered: ["A3"] })
105
+ }),
106
+ language: () => "zh"
107
+ });
108
+ const r = h(invocation("confirm P6"));
109
+ assert.equal(r.kind, "success");
110
+ assert.match(r.text, /记入账本/);
111
+ assert.match(r.text, /V2/);
112
+ assert.match(r.text, /A3/);
113
+ });
114
+
115
+ test("P2:confirm 的关键一条——什么都没确认时明说没落盘,不许含糊成完成", () => {
116
+ const h = createMoRunHandler({
117
+ recorder: fakeRecorder({
118
+ confirm: () => ({ ok: false, reason: "no-answers", notDone: 0, unsure: 1, unanswered: 1 })
119
+ }),
120
+ language: () => "zh"
121
+ });
122
+ const r = h(invocation("confirm P6:?"));
123
+ assert.equal(r.kind, "error");
124
+ assert.match(r.text, /没有落盘/, "必须明确说没写账");
125
+ });
126
+
127
+ test("P2:confirm 解析——`P6` 表示做了,`V2:-` 没做,`A3:?` 不确定", () => {
128
+ const seen = [];
129
+ const h = createMoRunHandler({
130
+ recorder: fakeRecorder({ confirm: (arg) => { seen.push(arg.answers); return { ok: true, pipelineId: "d", steps: 1, notDone: [], unsure: [], unanswered: [] }; } }),
131
+ language: () => "zh"
132
+ });
133
+ h(invocation("confirm P6 V2:- A3:?"));
134
+ assert.deepEqual(seen[0], { P6: "done", V2: "notDone", A3: "unsure" });
135
+ // 非法标记不放进结果(不是「没做」,是「没答」)
136
+ h(invocation("confirm P6:xyz"));
137
+ assert.deepEqual(seen[1], {});
138
+ // 逗号也当分隔符
139
+ h(invocation("confirm P6,V2"));
140
+ assert.deepEqual(seen[2], { P6: "done", V2: "done" });
141
+ });
142
+
143
+ test("P2:没待确认清单时 confirm 说清先做什么", () => {
144
+ const h = createMoRunHandler({
145
+ recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "nothing-to-confirm" }) }),
146
+ language: () => "zh"
147
+ });
148
+ const r = h(invocation("confirm P6"));
149
+ assert.match(r.text, /没有待确认/);
150
+ assert.match(r.text, /mo-run start/);
151
+ });
152
+
153
+ test("P2:非法回答/未知步骤各自说清", () => {
154
+ const bad = createMoRunHandler({
155
+ recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "bad-answer-value", stepId: "P6", value: "yes" }) }),
156
+ language: () => "zh"
157
+ });
158
+ assert.match(bad(invocation("confirm P6:yes")).text, /不合法/);
159
+
160
+ const unknown = createMoRunHandler({
161
+ recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "unknown-step", stepId: "NOPE" }) }),
162
+ language: () => "zh"
163
+ });
164
+ assert.match(unknown(invocation("confirm NOPE")).text, /NOPE/);
165
+ });
166
+
167
+ test("stop 没有开着窗口 / 写盘失败:各自说清,不混为一谈", () => {
168
+ const none = createMoRunHandler({
169
+ recorder: fakeRecorder({ stop: () => ({ ok: false, reason: "no-open-window" }) }),
170
+ language: () => "zh"
171
+ });
172
+ const failed = createMoRunHandler({
173
+ recorder: fakeRecorder({ stop: () => ({ ok: false, reason: "write-failed", detail: "磁盘满" }) }),
174
+ language: () => "zh"
175
+ });
176
+ assert.match(none(invocation("stop")).text, /没有开着窗口/);
177
+ assert.match(failed(invocation("stop")).text, /磁盘满/);
178
+ assert.notEqual(none(invocation("stop")).text, failed(invocation("stop")).text);
179
+ });
180
+
181
+ test("status:要能分辨「关着」「开着」「等你确认」三种状态", () => {
182
+ const closed = createMoRunHandler({ recorder: fakeRecorder({ status: () => ({ open: false }) }), language: () => "zh" });
183
+ assert.match(closed(invocation("status")).text, /没有开着/);
184
+
185
+ const awaiting = createMoRunHandler({ recorder: fakeRecorder({ status: () => ({ open: false, awaitingConfirmation: true, pipelineId: "demo", steps: 3 }) }), language: () => "zh" });
186
+ assert.match(awaiting(invocation("status")).text, /等你在确认|确认/, "等待确认是一个独立状态");
187
+
188
+ const open = createMoRunHandler({
189
+ recorder: fakeRecorder({
190
+ status: () => ({ open: true, pipelineId: "demo", startedAt: "2026-10-16T12:00:00.000Z", bufferedEvents: 7, recorded: ["P6"], unattributed: 1, ambiguous: 0, truncated: false })
191
+ }),
192
+ language: () => "zh"
193
+ });
194
+ const r = open(invocation("status"));
195
+ assert.match(r.text, /demo/);
196
+ assert.match(r.text, /7 条事件/);
197
+ assert.match(r.text, /P6/);
198
+ });
199
+
200
+ test("英文实例:同一组结果翻成英文,不落回中文", () => {
201
+ const h = createMoRunHandler({
202
+ recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "no-answers", notDone: 0, unsure: 1, unanswered: 1 }) }),
203
+ language: () => "en"
204
+ });
205
+ const r = h(invocation("confirm P6:?"));
206
+ assert.match(r.text, /nothing was persisted/i);
207
+ });
208
+
209
+ test("注册默认关:旗标不是 true 时一个命令都不注册", () => {
210
+ const registered = [];
211
+ const ctx = { commands: { register: (c) => { registered.push(c); return () => {}; } } };
212
+ assert.equal(registerMoRunCommand({ ctx, recorder: fakeRecorder(), enabled: false }), null);
213
+ assert.equal(registerMoRunCommand({ ctx, recorder: fakeRecorder(), enabled: undefined }), null);
214
+ assert.equal(registered.length, 0, "默认关时不许注册");
215
+ });
216
+
217
+ test("注册打开:命令名走小写字母起头的约定,且带描述与用法提示", () => {
218
+ const registered = [];
219
+ const ctx = { commands: { register: (c) => { registered.push(c); return () => {}; } } };
220
+ const dispose = registerMoRunCommand({ ctx, recorder: fakeRecorder(), enabled: true, language: () => "zh" });
221
+ assert.equal(typeof dispose, "function");
222
+ assert.equal(registered.length, 1);
223
+ assert.equal(registered[0].name, MO_RUN_COMMAND);
224
+ assert.match(registered[0].name, /^[a-z][a-z0-9_-]*$/);
225
+ assert.ok(registered[0].description);
226
+ assert.ok(registered[0].input?.hint);
227
+ assert.equal(typeof registered[0].handler, "function");
228
+ });
229
+
230
+ test("没有命令注册表时不硬注册(返回 null,由装配方决定要不要降级)", () => {
231
+ assert.equal(registerMoRunCommand({ ctx: {}, recorder: fakeRecorder(), enabled: true }), null);
232
+ assert.equal(registerMoRunCommand({ ctx: null, recorder: fakeRecorder(), enabled: true }), null);
233
+ });
234
+
235
+ test("handler 抛异常也不许把异常抛进宿主:退化成用法提示", () => {
236
+ const h = createMoRunHandler({
237
+ recorder: { start: () => { throw new Error("炸了"); }, stop: () => {}, status: () => {} },
238
+ language: () => "zh"
239
+ });
240
+ const r = h(invocation("start demo"));
241
+ assert.equal(r.kind, "error");
242
+ assert.ok(r.text);
243
+ });
@@ -0,0 +1,148 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildProposal, normalizeAnswers, runFromAnswers } from "../src/pipeline-confirm.js";
4
+ import { validatePipeline } from "../src/pipeline.js";
5
+
6
+ // 这组测试守的是 P2 的核心语义:**机器不猜,人确认才写账**。
7
+ // 最要紧的三条:
8
+ // ① 提案不改动任何东西(纯函数)
9
+ // ② unsure / 没回答 ≠ notDone——前者什么都不写,后者记 skipped
10
+ // ③ 一步都没确认走过 → 不产 run(账本契约不许零步),而不是伪造一条空的
11
+
12
+ function makePipeline() {
13
+ return validatePipeline({
14
+ id: "demo", name: "演示", project: "a/b", version: 0, status: "active", done: "走完",
15
+ trigger: [{ kind: "phrase", value: "走一遍" }],
16
+ steps: [
17
+ { id: "P6", phase: "p", title: "跑测试", when: "w", do: "d", who: "main", tool: "npm test", done: "dn", risk: "R0", needs: [], why: "y" },
18
+ { id: "V2", phase: "p", title: "推分支", when: "w", do: "d", who: "main", tool: "git push", done: "dn", risk: "R2", needs: ["P6"], why: "y" }
19
+ ]
20
+ }, "demo.json");
21
+ }
22
+
23
+ test("提案:列出流程步骤与观察到的动作,但**不预选任何一步**", () => {
24
+ const p = makePipeline();
25
+ const prop = buildProposal({
26
+ pipeline: p,
27
+ activities: [{ key: "npm test", count: 5, flags: [] }, { key: "git status", count: 3, flags: ["--porcelain"] }]
28
+ });
29
+ assert.equal(prop.ok, true);
30
+ assert.deepEqual(prop.steps.map((s) => s.id), ["P6", "V2"]);
31
+ // 步骤里带的是"作者写的 tool 原文",给对照用;没有任何自动勾选
32
+ assert.equal(prop.steps[0].declared, "npm test");
33
+ assert.equal(prop.steps[0].hintCount, 0, "没给候选时次数是 0");
34
+ assert.deepEqual(prop.activities.map((a) => a.key), ["npm test", "git status"]);
35
+ });
36
+
37
+ test("提案:候选只作提示(一个次数),不预选、不带命令行原文", () => {
38
+ const p = makePipeline();
39
+ const prop = buildProposal({ pipeline: p, hints: { P6: 3 } });
40
+ assert.equal(prop.steps[0].hintCount, 3);
41
+ // 关键:hintCount 只说明"见到过相近命令",没有任何字段表示"已经确认"
42
+ assert.equal("answer" in prop.steps[0], false);
43
+ assert.equal("selected" in prop.steps[0], false);
44
+ // 也不存命令行原文——原文含绝对路径(路径含本机用户名),而这条会进回执
45
+ assert.equal(JSON.stringify(prop).includes("command"), false);
46
+ });
47
+
48
+ test("提案:没有工作流时如实拒绝", () => {
49
+ assert.equal(buildProposal({ pipeline: null }).reason, "no-pipeline");
50
+ assert.equal(buildProposal({ pipeline: { id: "x" } }).reason, "no-pipeline");
51
+ });
52
+
53
+ test("校验回答:只认三类值,非法值一律拒(静默当「没发生」是编造)", () => {
54
+ const p = makePipeline();
55
+ assert.equal(normalizeAnswers({ P6: "done" }, p).ok, true);
56
+ const bad = normalizeAnswers({ P6: "yes" }, p);
57
+ assert.equal(bad.ok, false);
58
+ assert.equal(bad.reason, "bad-answer-value");
59
+ const unknown = normalizeAnswers({ NOPE: "done" }, p);
60
+ assert.equal(unknown.reason, "unknown-step");
61
+ assert.equal(normalizeAnswers(null, p).reason, "bad-answers");
62
+ assert.equal(normalizeAnswers([], p).reason, "bad-answers");
63
+ });
64
+
65
+ test("✅ 核心语义:done → uses;notDone → skipped;unsure/没答 → 什么都不写", () => {
66
+ const p = makePipeline();
67
+ const r = runFromAnswers({
68
+ pipeline: p,
69
+ answers: { P6: "done" }, // V2 没回答
70
+ now: () => new Date("2026-10-17T00:00:00.000Z")
71
+ });
72
+ assert.equal(r.kind, "run");
73
+ assert.deepEqual(r.run.steps.map((s) => s.id), ["P6"], "没回答的步骤不许进账");
74
+ assert.equal(r.run.steps[0].skipped, false);
75
+ assert.deepEqual(r.unanswered, ["V2"], "没答的要如实列出来");
76
+ assert.equal(r.run.by, "human", "人确认的账,by 应当是人");
77
+ });
78
+
79
+ test("✅ 核心语义:unsure 与 notDone 决不能混——前者不写,后者记 skipped", () => {
80
+ const p = makePipeline();
81
+ const unsure = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "unsure" } });
82
+ assert.deepEqual(unsure.run.steps.map((s) => s.id), ["P6"], "unsure 不进账");
83
+ assert.deepEqual(unsure.unsure, ["V2"]);
84
+
85
+ const notDone = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "notDone" } });
86
+ const v2 = notDone.run.steps.find((s) => s.id === "V2");
87
+ assert.equal(v2.skipped, true, "人明确说没做,才记 skipped");
88
+ assert.match(v2.note, /人确认/);
89
+ // 这条区分的意义:跳过计数正是 RETIRE_AFTER_SKIPS 提议删步的依据,
90
+ // 把"忘了勾"记成"被跳过"就会多出错误提议。
91
+ assert.notDeepEqual(unsure.run.steps.length, notDone.run.steps.length);
92
+ });
93
+
94
+ test("全部 notDone 仍是一条合法记录(人确认「这次一步都没做」是有信息的)", () => {
95
+ const p = makePipeline();
96
+ const r = runFromAnswers({ pipeline: p, answers: { P6: "notDone", V2: "notDone" } });
97
+ // 这不叫"零步"——它明确记录了"这两步这次都没做",是一条完整的、全跳过的 run。
98
+ // 真正进 nothing 的是「什么都没确认」(见下一条)。
99
+ assert.equal(r.kind, "run");
100
+ assert.deepEqual(r.run.steps.map((s) => s.id), ["P6", "V2"]);
101
+ assert.equal(r.run.steps.every((s) => s.skipped === true), true);
102
+ assert.deepEqual(r.notDone, ["P6", "V2"]);
103
+ });
104
+
105
+ test("什么都没确认(全 unsure):不产 run——这才是真正的「零步」", () => {
106
+ const p = makePipeline();
107
+ const r = runFromAnswers({ pipeline: p, answers: { P6: "unsure", V2: "unsure" } });
108
+ assert.equal(r.kind, "nothing");
109
+ assert.equal(r.reason, "no-steps-confirmed");
110
+ assert.equal("run" in r, false, "不许伪造一条空 run");
111
+ assert.deepEqual(r.unsure, ["P6", "V2"]);
112
+ assert.deepEqual(r.notDone, []);
113
+ });
114
+
115
+ test("空回答也是 nothing(没答 ≠ 都没做)", () => {
116
+ const p = makePipeline();
117
+ const r = runFromAnswers({ pipeline: p, answers: {} });
118
+ assert.equal(r.kind, "nothing");
119
+ assert.deepEqual(r.unanswered, ["P6", "V2"]);
120
+ assert.deepEqual(r.notDone, [], "没人说没做,就不该出现在 notDone 里");
121
+ });
122
+
123
+ test("非法回答进不来:runFromAnswers 会把它变成 error,而不是悄悄少记一步", () => {
124
+ const p = makePipeline();
125
+ const r = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "nope" } });
126
+ assert.equal(r.kind, "error");
127
+ assert.equal(r.reason, "bad-answer-value");
128
+ assert.equal("run" in r, false);
129
+ });
130
+
131
+ test("产出的 run 能过账本 normalizer(两个契约对得上)", async () => {
132
+ const { normalizeRun } = await import("../src/pipeline-runs.js");
133
+ const p = makePipeline();
134
+ const r = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "notDone" } });
135
+ const normalized = normalizeRun(r.run);
136
+ assert.equal(normalized.steps.length, 2);
137
+ assert.equal(normalized.steps[0].skipped, false);
138
+ assert.equal(normalized.steps[1].skipped, true);
139
+ assert.equal(normalized.by, "human");
140
+ });
141
+
142
+ test("坏输入不抛", () => {
143
+ const p = makePipeline();
144
+ for (const bad of [null, undefined, [], "x", 0]) {
145
+ assert.equal(runFromAnswers({ pipeline: p, answers: bad }).kind, "error");
146
+ }
147
+ assert.equal(runFromAnswers({ pipeline: null, answers: {} }).kind, "error");
148
+ });
@@ -0,0 +1,172 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { openRun, OBSERVED, UNATTRIBUTED, AMBIGUOUS } from "../src/pipeline-exec.js";
4
+ import { validatePipeline } from "../src/pipeline.js";
5
+
6
+ // 这组测试守的是 M.O. 的**执行窗口**:有人显式说「按 X 走一遍」,窗口才开;窗口内的
7
+ // 工具调用才可能被记账;关窗产出一条 run。
8
+ //
9
+ // 最要紧的三条性质(都是「不许编造」的具体形态):
10
+ // ① 窗口外发生的事一个字都不记——漏记可接受,编造不可
11
+ // ② 归属认不出来 → 进 unattributed,**不写进 run.steps**(不是「这步跳过了」)
12
+ // ③ 没观察到的步骤 → 进 unobserved,既不写 skipped 也不写 used(缺席 ≠ 跳过)
13
+
14
+ function pipelineOf(steps) {
15
+ return validatePipeline({
16
+ id: "demo",
17
+ name: "演示",
18
+ project: "someone/repo",
19
+ version: 0,
20
+ status: "active",
21
+ done: "走完",
22
+ trigger: [{ kind: "phrase", value: "走一遍" }],
23
+ steps: steps.map((s) => ({
24
+ id: s.id,
25
+ phase: "准备",
26
+ title: `第 ${s.id} 步`,
27
+ when: "需要时",
28
+ do: "做事",
29
+ who: "main",
30
+ tool: s.tool ?? "",
31
+ done: "做完",
32
+ risk: "R0",
33
+ needs: s.needs ?? [],
34
+ why: "因为"
35
+ }))
36
+ }, "demo.json");
37
+ }
38
+
39
+ const clock = (() => {
40
+ let n = 0;
41
+ return () => new Date(Date.UTC(2026, 9, 16, 12, 0, n++));
42
+ })();
43
+
44
+ test("开窗需要一条工作流:没有 steps 直接拒,不静默建一个空窗口", () => {
45
+ assert.throws(() => openRun({ pipeline: null }), /需要一条工作流/);
46
+ assert.throws(() => openRun({ pipeline: { id: "x" } }), /需要一条工作流/);
47
+ });
48
+
49
+ test("显式点名归属:人说了算,不再走匹配", () => {
50
+ const p = pipelineOf([{ id: "A1", tool: "npm test" }]);
51
+ const run = openRun({ pipeline: p, by: "human", triggeredBy: "发版", now: clock });
52
+ const r = run.observe({ tool: "bash", command: "随便什么命令" }, { stepId: "A1" });
53
+ assert.equal(r.result, OBSERVED);
54
+ assert.equal(r.resolved, "explicit", "人给的归属不该被自动匹配覆盖");
55
+
56
+ const closed = run.close();
57
+ assert.equal(closed.run.steps.length, 1);
58
+ assert.equal(closed.run.steps[0].id, "A1");
59
+ assert.equal(closed.run.by, "human");
60
+ assert.equal(closed.run.triggeredBy, "发版");
61
+ });
62
+
63
+ test("自动归属只认唯一 exact:weak 与多候选走歧义桶,绝不自选一条", () => {
64
+ const p = pipelineOf([
65
+ { id: "P6", tool: "npm test" },
66
+ { id: "P5", tool: "npm run badge:sync" }
67
+ ]);
68
+ const run = openRun({ pipeline: p, now: clock });
69
+
70
+ // 唯一 exact → 自动记账
71
+ assert.equal(run.observe({ command: "npm test" }).result, OBSERVED);
72
+
73
+ // weak(词前缀但不相等)→ 歧义,不记账
74
+ const weak = run.observe({ command: "npm run badge:sync --all" });
75
+ assert.equal(weak.result, AMBIGUOUS, "弱匹配必须请人确认,不能自动写账");
76
+
77
+ // 认不出来 → unattributed
78
+ assert.equal(run.observe({ command: "docker ps" }).result, UNATTRIBUTED);
79
+
80
+ const closed = run.close();
81
+ assert.deepEqual(closed.run.steps.map((s) => s.id), ["P6"], "只有被确认的那步进账");
82
+ assert.equal(closed.ambiguous.length, 1);
83
+ assert.equal(closed.unattributed.length, 1);
84
+ });
85
+
86
+ test("同一步被观察到多次只计一次,次数进 note(tallyRuns 按 run 计数,重复会让 uses 虚高)", () => {
87
+ const p = pipelineOf([{ id: "P6", tool: "npm test" }]);
88
+ const run = openRun({ pipeline: p, now: clock });
89
+ run.observe({ command: "npm test" });
90
+ run.observe({ command: "npm test" });
91
+ run.observe({ command: "npm test" });
92
+ const closed = run.close();
93
+ assert.equal(closed.run.steps.length, 1, "三次观察仍是一步");
94
+ assert.match(closed.run.steps[0].note, /3 次/);
95
+ });
96
+
97
+ test("任一环节失败就永久标记 refuted:后来的成功不许抹掉跑砸过一次这件事", () => {
98
+ const p = pipelineOf([{ id: "P6", tool: "npm test" }]);
99
+ const run = openRun({ pipeline: p, now: clock });
100
+ run.observe({ command: "npm test", ok: false }); // 先失败
101
+ run.observe({ command: "npm test", ok: true }); // 再成功
102
+ const closed = run.close();
103
+ assert.equal(closed.run.steps[0].refuted, true, "失败过就是失败过,不该被后一次成功洗掉");
104
+ });
105
+
106
+ test("没被观察到的步骤进 unobserved:既不写 skipped 也不写 used——缺席不等于跳过", () => {
107
+ const p = pipelineOf([
108
+ { id: "A1", tool: "npm test" },
109
+ { id: "A2", tool: "git diff" },
110
+ { id: "A3", tool: "" } // 空 tool:结构性地无法被归属
111
+ ]);
112
+ const run = openRun({ pipeline: p, now: clock });
113
+ run.observe({ command: "npm test" });
114
+ const closed = run.close();
115
+
116
+ assert.deepEqual(closed.run.steps.map((s) => s.id), ["A1"]);
117
+ assert.deepEqual(closed.unobserved, ["A2", "A3"]);
118
+ // 关键:没观察到的步骤不许出现在 steps 里被标成 skipped——那是编造「这步被跳过了」。
119
+ assert.equal(closed.run.steps.some((s) => s.skipped === true), false);
120
+ assert.equal(closed.run.steps.some((s) => s.detoured === true), false);
121
+ });
122
+
123
+ test("一步都没走成:如实返回 no-steps,不伪造一条空 run 去凑契约", () => {
124
+ const p = pipelineOf([{ id: "A1", tool: "npm test" }]);
125
+ const run = openRun({ pipeline: p, now: clock });
126
+ run.observe({ command: "完全无关的命令" });
127
+ const closed = run.close();
128
+
129
+ assert.equal(closed.run, null, "账本契约要求至少一步,所以这里不能有 run");
130
+ assert.equal(closed.reason, "no-steps");
131
+ assert.equal(closed.unattributed.length, 1, "但这次观察要留着,别丢");
132
+ });
133
+
134
+ test("窗口关掉之后不再接受观察:不悄悄记进一个已经结完的账", () => {
135
+ const p = pipelineOf([{ id: "A1", tool: "npm test" }]);
136
+ const run = openRun({ pipeline: p, now: clock });
137
+ run.observe({ command: "npm test" });
138
+ const first = run.close();
139
+ const after = run.observe({ command: "npm test" });
140
+
141
+ assert.equal(after.accepted, false);
142
+ assert.equal(after.result, "run-closed");
143
+ assert.equal(run.close(), first, "重复 close 返回同一份,不产生第二份账");
144
+ });
145
+
146
+ test("state() 只报状态不结账:窗口开着时也能回答「走到哪了」", () => {
147
+ const p = pipelineOf([{ id: "A1", tool: "npm test" }, { id: "A2", tool: "git diff" }]);
148
+ const run = openRun({ pipeline: p, now: clock });
149
+ run.observe({ command: "npm test" });
150
+ run.observe({ command: "乱七八糟" });
151
+ const s = run.state();
152
+
153
+ assert.equal(s.open, true);
154
+ assert.equal(s.observations, 2);
155
+ assert.deepEqual(s.recorded, ["A1"]);
156
+ assert.equal(s.unattributed, 1);
157
+ assert.equal(s.ambiguous, 0);
158
+ });
159
+
160
+ test("产出的 run 能过账本的 normalizer:窗口与账本两个契约对得上", async () => {
161
+ const { normalizeRun } = await import("../src/pipeline-runs.js");
162
+ const p = pipelineOf([{ id: "A1", tool: "npm test" }]);
163
+ const run = openRun({ pipeline: p, now: clock });
164
+ run.observe({ command: "npm test", ok: false });
165
+ const closed = run.close();
166
+
167
+ // 不传 expectedStepIds:这里验的是形状能被账本接受
168
+ const normalized = normalizeRun(closed.run);
169
+ assert.equal(normalized.steps[0].id, "A1");
170
+ assert.equal(normalized.steps[0].refuted, true);
171
+ assert.equal(normalized.by, "main");
172
+ });