@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,442 @@
1
+ /**
2
+ * M.O.(Modus Operandi · 手法)工作流的**运行账**。
3
+ *
4
+ * 读侧(`src/pipeline.js`)已经能把「一条工作流跑了怎么样」算成四态,但账本一直没人写,
5
+ * 于是 `runs` 恒为空数组、节点状态恒为 `candidate`。本模块补的就是这一环:把一次 run
6
+ * 落到盘上、再读回来成读侧认得的那份形状。
7
+ *
8
+ * **落点为什么跟手写的 `.json` 分开**:`<项目>/.mneme/pipelines/<id>.json` 是人手写、
9
+ * 要 review、要提交的;账本是每跑一次就追加一行的流水。挤进同一个文件,一次 run 就会
10
+ * 把人的 diff 冲掉,而人改过的内容与机器追加的内容此后再也分不开。
11
+ *
12
+ * **为什么是 JSONL 不是一个大 JSON 数组**:追加一条要重写整个数组,而这个文件会被
13
+ * 「刚刚跑完一次」这种**不能失败**的动作写。一行一条时,写到一半断电只毁最后一行;
14
+ * 整份重写时,同样的故障会毁掉全部历史。
15
+ *
16
+ * 形状以读侧的 `tallyRuns` 为准(那里是权威):只有 `skipped` 与 `detoured` 都为假才算
17
+ * 一次 `uses`,两者各自独立累加,`refuted` 只当布尔用,`step.at` 取最大值当 `lastUsedAt`。
18
+ *
19
+ * **读盘与写盘刻意不共用一条校验路径**(两者要的东西不一样):
20
+ * - 写盘是**我们自己在说话**,缺的字段意思明确(没写就是没有),补默认值是替它把话说完;
21
+ * - 读盘是**听别人说话**,缺一个布尔就等于「这一步照图走了」这条正面证据是凭空来的
22
+ * ——读侧宁可不认这条记录(报噪 + 跳过),也不能替它补一个含义。
23
+ * 早期版本两处共用一个函数,于是一行只有 `{id, at}` 的记录被算成一次成功使用、把节点
24
+ * 染成 `trusted`:账本没说话,面板却在作证。这类错比读不出来严重得多。
25
+ *
26
+ * **一批读不出来 ≠ 整份账本报废**:读盘逐行收,坏行进 `problems`、好行照收。一份账本
27
+ * 因为一行被截断就永久读不出来,等于让一次断电抹掉全部历史,而写侧还会继续报成功。
28
+ */
29
+
30
+ import {
31
+ appendFileSync,
32
+ closeSync,
33
+ existsSync,
34
+ fstatSync,
35
+ mkdirSync,
36
+ openSync,
37
+ readFileSync,
38
+ readSync
39
+ } from "node:fs";
40
+ import { isAbsolute, join, resolve } from "node:path";
41
+ import { ACTORS } from "./pipeline.js";
42
+
43
+ /** 账本的后缀:与手写工作流的 `.json` 分开,正是为了避免同 id 撞名。 */
44
+ export const RUNS_SUFFIX = ".runs.jsonl";
45
+
46
+ /**
47
+ * 账本的五种状态。存在的理由是**「读不出来」与「还没跑过」在节点状态上完全同形**
48
+ * (都是全体 `candidate`):不把它们分开,面板就会把一次「历史读不出来了」画成
49
+ * 「这条工作流还没被验证过」——这是两种完全不同的说法,而后者让人安心。
50
+ *
51
+ * absent 盘上没有这个账本文件
52
+ * empty 文件在、一条 run 都没有(有人碰过它,但还没写进一次运行)
53
+ * ok 读得干净,至少一条 run
54
+ * partial 收到一部分,另有一些行读不出来(行号在 problems 里)
55
+ * broken 文件在,但一条可用的 run 都收不到(含整份读不进来)
56
+ */
57
+ export const RUNS_STATES = ["absent", "empty", "ok", "partial", "broken"];
58
+
59
+ /** 校验失败的统一出口。名字与 `PipelineError` 成对:一个是工作流写坏了,一个是账本写坏了。 */
60
+ export class RunError extends Error {
61
+ constructor(message) {
62
+ super(message);
63
+ this.name = "RunError";
64
+ }
65
+ }
66
+
67
+ const bad = (msg) => {
68
+ throw new RunError(msg);
69
+ };
70
+
71
+ /** 报错时回显原始行/值的上限:账本行可能很长,别把整份文件塞进一条错误。 */
72
+ const MAX_ECHO = 120;
73
+
74
+ const echo = (value) => JSON.stringify(value).slice(0, MAX_ECHO);
75
+
76
+ /**
77
+ * id 只当**一个文件名片段**用,绝不当路径用。
78
+ *
79
+ * 拒掉而不是清洗:清洗会把 `../../x` 悄悄变成 `x`,于是写到了调用方没指定的文件上
80
+ * ——那类错在界面上看不出来,是最难查的一种。`..` 单独一个也要拒:它合法地通过
81
+ * 「无分隔符」检查,却仍然指向上级目录。
82
+ */
83
+ function isSafeSegment(id) {
84
+ if (typeof id !== "string") return false;
85
+ const value = id.trim();
86
+ if (value === "" || value === "." || value === "..") return false;
87
+ if (value.includes("/") || value.includes("\\")) return false;
88
+ if (isAbsolute(value)) return false;
89
+ return true;
90
+ }
91
+
92
+ /**
93
+ * 账本的家:`<项目>/.mneme/pipelines/`。目录名与读侧同一处来源(同一段路径拼接),
94
+ * 免得两边各写一份常量、改一处漏一处。
95
+ */
96
+ export function runsDir(projectRoot) {
97
+ if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
98
+ bad("projectRoot 必须是项目根的路径");
99
+ }
100
+ return join(projectRoot, ".mneme", "pipelines");
101
+ }
102
+
103
+ /** 一个 id 的账本文件:`<项目>/.mneme/pipelines/<id>.runs.jsonl`。 */
104
+ export function runsPath(projectRoot, id) {
105
+ const dir = runsDir(projectRoot);
106
+ if (!isSafeSegment(id)) bad(`id 不是合法的文件名片段:${echo(id)}`);
107
+ const full = resolve(dir, id.trim() + RUNS_SUFFIX);
108
+ // 双保险:片段检查已经挡掉了穿越,这里再确认落点确实在账本目录里
109
+ // ——将来有人放宽上面的检查时,这一层会先变红。
110
+ if (!full.startsWith(resolve(dir))) bad(`id 会写到账本目录之外:${echo(id)}`);
111
+ return full;
112
+ }
113
+
114
+ /**
115
+ * 时间戳收成规范的 ISO 毫秒 UTC 串。
116
+ *
117
+ * 收成**唯一一种**形状是刻意的:`step.at` 会被读侧按字符串比较取最大值当 `lastUsedAt`,
118
+ * 同一条账里混着 `2026-10-15T09:00:01Z` 与 `2026-10-15T09:00:01.500Z` 时,比较结果
119
+ * 与人对时间先后的直觉不一致。写进来/读回来的那一刻就把它们拉齐。
120
+ */
121
+ function normalizeAt(value, where) {
122
+ if (value instanceof Date) {
123
+ if (Number.isNaN(value.getTime())) bad(`${where} 是一个无效的 Date`);
124
+ return value.toISOString();
125
+ }
126
+ if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
127
+ if (typeof value === "string") {
128
+ const trimmed = value.trim();
129
+ if (trimmed === "") bad(`${where} 不能是空字符串`);
130
+ const ms = Date.parse(trimmed);
131
+ if (!Number.isFinite(ms)) bad(`${where} 不是能解析的时间:${echo(value)}`);
132
+ return new Date(ms).toISOString();
133
+ }
134
+ bad(`${where} 必须是 ISO 时间串、Date 或毫秒数,收到 ${value === null ? "null" : typeof value}`);
135
+ }
136
+
137
+ function requireRecord(value, where) {
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) bad(`${where} 必须是一个对象`);
139
+ return value;
140
+ }
141
+
142
+ /**
143
+ * 写盘的布尔:缺了补 `false`。调用方是「我们自己」,没写就是不打算标注。
144
+ */
145
+ function writeBool(value, where) {
146
+ if (value === undefined || value === null) return false;
147
+ if (typeof value !== "boolean") bad(`${where} 必须是布尔`);
148
+ return value;
149
+ }
150
+
151
+ /**
152
+ * 读盘的布尔:**缺了就是错**,绝不补默认。
153
+ *
154
+ * 补 `false` 的后果不是「少一个标注」而是「多一条证据」:`tallyRuns` 只有看到两个布尔
155
+ * 都为假才算一次 `uses`,于是缺字段的行会被读成「照图走了」,节点由 `candidate` 变
156
+ * `trusted`。账本没说话,面板却在作证——这是读侧能犯的最重的一类错。
157
+ */
158
+ function readBool(step, key, where) {
159
+ if (typeof step[key] !== "boolean") {
160
+ bad(`${where}.${key} 缺失或不是布尔——账本必须自己说清这一步的结局,读侧不替它补`);
161
+ }
162
+ return step[key];
163
+ }
164
+
165
+ /**
166
+ * `kind` 是绕行的**旧写法别名**(读侧 `tallyRuns` 至今认 `step.kind === "detour"`)。
167
+ *
168
+ * 别名只有被认下来才有意义:归一化只留契约那六个键,一个不认识的 `kind` 被默默丢掉时,
169
+ * 丢掉的是**这一步的结局本身**——一次绕行会在「写→读」往返后变成一次成功使用。
170
+ * 所以这里不但要认 `"detour"`,还要把不认识的取值当场拒掉,而不是放它过去。
171
+ */
172
+ function isDetourAlias(step, where) {
173
+ if (step.kind === undefined || step.kind === null) return false;
174
+ if (step.kind === "detour") return true;
175
+ bad(`${where}.kind 只认 "detour"(其余取值读侧会忽略,等于丢掉这一步的结局),收到 ${echo(step.kind)}`);
176
+ }
177
+
178
+ /**
179
+ * 把一条 run 收成契约形状(**写盘路径**):缺的补默认、多余的丢掉、坏的直接抛。
180
+ *
181
+ * 「坏的直接抛」与读侧 `loadPipelines` 同一纪律:**静默跳过最难查**——账本里少一条,
182
+ * 面板上看起来只是「这条工作流好像没跑过」,而真相是有一次运行丢了。
183
+ *
184
+ * `expectedStepIds` 给的是「图上有哪些步骤」。给了却不查,写错的 step id 会落盘、能被
185
+ * 读出来,却没有任何节点拿到它——症状与「从没跑过」同形,比丢掉更隐蔽。
186
+ */
187
+ export function normalizeRun(raw, { expectedStepIds = null } = {}) {
188
+ const run = requireRecord(raw, "run");
189
+ const at = normalizeAt(raw.at, "run.at");
190
+
191
+ const by = typeof raw.by === "string" ? raw.by.trim() : raw.by;
192
+ if (!ACTORS.includes(by)) bad(`run.by 必须是 ${ACTORS.join(" / ")} 之一,收到 ${echo(raw.by)}`);
193
+
194
+ const triggeredBy = raw.triggeredBy === undefined || raw.triggeredBy === null ? "" : raw.triggeredBy;
195
+ if (typeof triggeredBy !== "string") {
196
+ bad(`run.triggeredBy 必须是字符串(trigger.value 或空串),收到 ${typeof raw.triggeredBy}`);
197
+ }
198
+
199
+ if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
200
+ bad("run.steps 至少要有一条——一次什么步骤都没走过的 run 不提供任何账");
201
+ }
202
+ const steps = normalizeSteps(raw.steps, { expectedStepIds, writing: true });
203
+ return { at, by, triggeredBy, steps };
204
+ }
205
+
206
+ /**
207
+ * 绕行有两个来源:`detoured:true` 与旧写法 `kind:"detour"`(读侧至今认它)。
208
+ * 两者是**或**的关系,所以「只给了 kind」不算缺字段;但一个都不给、给错类型、
209
+ * 或者给了一个读侧不认识的 kind,都必须当场报出来——那丢的是这一步的结局本身。
210
+ */
211
+ function detouredOf(step, where, writing) {
212
+ const alias = isDetourAlias(step, where);
213
+ if (writing) return writeBool(step.detoured, `${where}.detoured`) || alias;
214
+ if (typeof step.detoured === "boolean") return step.detoured || alias;
215
+ if (alias) return true;
216
+ bad(`${where}.detoured 缺失或不是布尔——账本必须自己说清这一步的结局,读侧不替它补`);
217
+ }
218
+
219
+ /**
220
+ * 步骤表的**共同骨架**:写盘(补默认)与读盘(缺就报错)只差「布尔缺了怎么办」,
221
+ * 其余校验两边必须一致——不一致的地方就是语义分叉的地方。
222
+ */
223
+ function normalizeSteps(rawSteps, { expectedStepIds = null, writing = false } = {}) {
224
+ const expected = expectedStepIds ? new Set(expectedStepIds) : null;
225
+ const seen = new Set();
226
+ return rawSteps.map((rawStep, i) => {
227
+ const where = `run.steps[${i}]`;
228
+ const step = requireRecord(rawStep, `${where} 必须是一个对象`);
229
+ const id = typeof step.id === "string" ? step.id.trim() : step.id;
230
+ if (!isSafeSegment(id)) bad(`${where}.id 不是合法的步骤 id:${echo(step.id)}`);
231
+ // 同一条 run 里重复的 step id 会被 `tallyRuns` 数两次,使用次数于是虚高。
232
+ if (seen.has(id)) bad(`${where}.id "${id}" 在同一条 run 里出现了两次`);
233
+ seen.add(id);
234
+ // 图里没有的步骤 id:账落在一个没有节点的地方——数得出来,面板上永远看不见。
235
+ if (expected && !expected.has(id)) {
236
+ bad(`${where}.id "${id}" 不在工作流的步骤表里(${[...expected].join(" / ")})`);
237
+ }
238
+ const boolAt = (key) => (writing ? writeBool(step[key], `${where}.${key}`) : readBool(step, key, where));
239
+ const note = step.note === undefined || step.note === null ? "" : step.note;
240
+ if (typeof note !== "string") bad(`${where}.note 必须是字符串`);
241
+ if (writing && step.at === undefined) bad(`${where}.at 缺失——时间不做「补默认」:一次 run 什么时候跑的只有调用方知道`);
242
+ return {
243
+ id,
244
+ at: normalizeAt(step.at, `${where}.at`),
245
+ skipped: boolAt("skipped"),
246
+ detoured: detouredOf(step, where, writing),
247
+ refuted: boolAt("refuted"),
248
+ note
249
+ };
250
+ });
251
+ }
252
+
253
+ /**
254
+ * 读盘路径的校验:**一条记录的形状**。缺布尔、缺 step、id 越界、kind 不认识,都抛
255
+ * (由 `readRuns` 收成 problems 里的行号 + 原因)。
256
+ *
257
+ * `by` 只在**给了**的时候校验:它是描述字段,读侧投影不消费它;而 `at` 与三个布尔是
258
+ * 被消费的、会让计数变样的字段,必须齐。
259
+ */
260
+ function readStoredRun(raw, { expectedStepIds = null } = {}) {
261
+ const run = requireRecord(raw, "顶层必须是一个对象");
262
+ if (run.at === undefined) bad("run.at 缺失");
263
+ const at = normalizeAt(run.at, "run.at");
264
+ if (run.by !== undefined) {
265
+ const by = typeof run.by === "string" ? run.by.trim() : run.by;
266
+ if (!ACTORS.includes(by)) bad(`run.by 必须是 ${ACTORS.join(" / ")} 之一,收到 ${echo(run.by)}`);
267
+ }
268
+ const triggeredBy = run.triggeredBy === undefined || run.triggeredBy === null ? "" : run.triggeredBy;
269
+ if (typeof triggeredBy !== "string") bad("run.triggeredBy 必须是字符串");
270
+ if (!Array.isArray(run.steps) || run.steps.length === 0) bad("run.steps 至少要有一条");
271
+ return {
272
+ at,
273
+ by: run.by === undefined || run.by === null ? "" : String(run.by).trim(),
274
+ triggeredBy,
275
+ steps: normalizeSteps(run.steps, { expectedStepIds, writing: false })
276
+ };
277
+ }
278
+
279
+ /** 一行的问题:行号(文件级问题为 null)、是否只是一截尾巴、给人看的原因。 */
280
+ function problem(line, detail, truncated) {
281
+ return { line, truncated: truncated === true, detail };
282
+ }
283
+
284
+ function stateOf(runs, problems) {
285
+ if (problems.length === 0) return runs.length ? "ok" : "empty";
286
+ return runs.length ? "partial" : "broken";
287
+ }
288
+
289
+ /**
290
+ * 读一个项目的账本,**逐行收**:好行照收,坏行进 `problems`,并给出一个 `state`。
291
+ *
292
+ * 为什么坏行不再「一条就抛」:一条被截断的尾行(写到一半断电、编辑器只保存了一半)
293
+ * 会让整份账本永久读不出来,而写侧还在继续报成功——一次断电就抹掉全部历史。
294
+ * 那条尾行是**可判定**的(最后一行 + 文件不以换行结尾),所以报噪、跳过、其余照收;
295
+ * 中间的坏行同样报噪跳过,不再判死整份文件。**报噪 ≠ 静默**:行号与原因都在 `problems` 里,
296
+ * 端点把它带成 `runsErrors`,状态降成 partial/broken。
297
+ *
298
+ * 目录不存在、文件不存在、文件是空的 → 空数组 + `absent`/`empty`:一条工作流还没跑过
299
+ * 是正常状态,面板据此画空态而不是画成「读不出来」。
300
+ */
301
+ export function readRuns(projectRoot, id, { expectedStepIds = null } = {}) {
302
+ const file = runsPath(projectRoot, id);
303
+ if (!existsSync(file)) return { runs: [], problems: [], state: "absent" };
304
+ let text;
305
+ try {
306
+ text = readFileSync(file, "utf8");
307
+ } catch (error) {
308
+ // 文件在、读不出来:这是「坏了」,不是「还没跑过」——两者在面板上必须分得开。
309
+ return { runs: [], problems: [problem(null, `读不出来:${error.message}`, false)], state: "broken" };
310
+ }
311
+ // UTF-8 BOM 不是数据,但它会让第一行 `JSON.parse` 失败、整份账本从此读不出来。
312
+ // 这是**可修复**的故障:BOM 是写文件的工具塞进去的,不是账本的内容。
313
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
314
+ if (text.trim() === "") return { runs: [], problems: [], state: "empty" };
315
+
316
+ const endsWithNewline = /(\r\n|\n|\r)$/.test(text);
317
+ const lines = text.split(/\r\n|\n|\r/);
318
+ // 文件末尾的空行没有数据(多按一次回车就会产生),不算「一行账」;
319
+ // **中间**的空行是坏行,下面照报。
320
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
321
+
322
+ const runs = [];
323
+ const problems = [];
324
+ lines.forEach((line, index) => {
325
+ const lineNo = index + 1;
326
+ if (line.trim() === "") {
327
+ problems.push(problem(lineNo, "空行", false));
328
+ return;
329
+ }
330
+ let parsed;
331
+ try {
332
+ parsed = JSON.parse(line);
333
+ } catch (error) {
334
+ // 可判定的截断:最后一行 + 文件不以换行结尾。这只可能是写到一半断了,
335
+ // 与「中间某行是垃圾」不是一回事,但两者都只影响自己这一行。
336
+ const truncated = index === lines.length - 1 && !endsWithNewline;
337
+ problems.push(problem(
338
+ lineNo,
339
+ `${truncated ? "末行被截断" : "JSON 解析失败"}(${error.message});这一行是 ${line.slice(0, MAX_ECHO)}`,
340
+ truncated
341
+ ));
342
+ return;
343
+ }
344
+ try {
345
+ runs.push(readStoredRun(parsed, { expectedStepIds }));
346
+ } catch (error) {
347
+ problems.push(problem(lineNo, error.message, false));
348
+ }
349
+ });
350
+
351
+ return { runs, problems, state: stateOf(runs, problems) };
352
+ }
353
+
354
+ /**
355
+ * 只看账本**尾 1 字节**:上一条有没有以换行收尾。
356
+ *
357
+ * 为什么不能整份读:这是每次追加都要做的一步,而账本只增不减——整份读会让热路径的
358
+ * 代价随历史长度线性增长(一次 run 一次全文读)。尾字节用 open + fstat + read 取,
359
+ * 与账本多大无关。
360
+ *
361
+ * 读尾字节失败时退回「不补换行」:这里不是失败该现形的地方,真正的写入失败会在
362
+ * `appendFileSync` 那一步抛出来。
363
+ */
364
+ function tailNeedsNewline(file) {
365
+ let fd;
366
+ try {
367
+ fd = openSync(file, "r");
368
+ } catch {
369
+ return false; // 文件还不存在 = 空账本,不需要补
370
+ }
371
+ try {
372
+ const size = fstatSync(fd).size;
373
+ if (size === 0) return false;
374
+ const tail = Buffer.allocUnsafe(1);
375
+ readSync(fd, tail, 0, 1, size - 1);
376
+ return tail[0] !== 0x0a; // 0x0a = "\n"
377
+ } catch {
378
+ return false;
379
+ } finally {
380
+ closeSync(fd);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * 追加一条 run(一次 run 一行)。返回值是归一化后的那条,便于调用方回执。
386
+ *
387
+ * 补换行的理由:少了它,追加会把上一条与本条**粘成一行**,于是一行坏 JSON 让整个
388
+ * 账本读不出来——而账本是「刚刚跑完一次」时才动的文件,恰恰最不该在这种地方失败。
389
+ *
390
+ * 写入是严格的:`expectedStepIds` 给了就当场拒(写错的 step id 落到盘上之后,
391
+ * 面板上跟「从没跑过」长得一模一样,那时再查就晚了)。
392
+ */
393
+ export function appendRun(projectRoot, id, run, { expectedStepIds = null } = {}) {
394
+ const file = runsPath(projectRoot, id);
395
+ const normalized = normalizeRun(run, { expectedStepIds });
396
+ mkdirSync(runsDir(projectRoot), { recursive: true });
397
+ const prefix = tailNeedsNewline(file) ? "\n" : "";
398
+ try {
399
+ appendFileSync(file, prefix + JSON.stringify(normalized) + "\n", "utf8");
400
+ } catch (error) {
401
+ throw new RunError(`${id}${RUNS_SUFFIX}: 写不进去:${error.message}`);
402
+ }
403
+ return normalized;
404
+ }
405
+
406
+ /**
407
+ * 一次问一批 id 的账本,供 `/pipeline` 端点喂给 `readPipelineDoc`。
408
+ *
409
+ * 一条账本坏了不该让整个面板打不开(与「项目层坏一个 .json 不拖死全局层」同一口径),
410
+ * 但**也不能假装它没坏**:坏行改从 `errors` 里如实回来(带行号),状态经由 `statesById`
411
+ * 显式带出去。缺账本与坏账本因此长得不一样。
412
+ *
413
+ * `expectedStepIdsById` 是「每条工作流的步骤表」:给了就顺手把「账本里写了图上没有的
414
+ * step id」变成可见的噪音——那种行能落盘、能数出来,却没有任何节点拿到它。
415
+ */
416
+ export function loadRunsById(projectRoot, ids, { expectedStepIdsById = null } = {}) {
417
+ const runsById = Object.create(null);
418
+ const statesById = Object.create(null);
419
+ const errors = [];
420
+ for (const id of ids || []) {
421
+ const expected = expectedStepIdsById && expectedStepIdsById[id] ? expectedStepIdsById[id] : null;
422
+ try {
423
+ const { runs, problems, state } = readRuns(projectRoot, id, { expectedStepIds: expected });
424
+ runsById[id] = runs;
425
+ statesById[id] = state;
426
+ for (const p of problems) {
427
+ errors.push({
428
+ id,
429
+ line: p.line,
430
+ truncated: p.truncated,
431
+ detail: `${id}${RUNS_SUFFIX}${p.line === null ? "" : ` 第 ${p.line} 行`}: ${p.detail}`
432
+ });
433
+ }
434
+ } catch (error) {
435
+ // 连文件都定位不到(id 本身不安全之类):如实记一条,状态按「读不出来」算。
436
+ runsById[id] = [];
437
+ statesById[id] = "broken";
438
+ errors.push({ id, line: null, truncated: false, detail: String((error && error.message) || error) });
439
+ }
440
+ }
441
+ return { runsById, errors, statesById };
442
+ }
package/src/pipeline.js CHANGED
@@ -109,24 +109,83 @@ export const RETIRE_AFTER_DETOURS = 3;
109
109
  * 两者的区别是这一版的核心:**跳过 = 这次不用;绕开 = 这条路整个不对。**
110
110
  * 只有绕开才动「路径」这一层。
111
111
  */
112
- export function suggestRetirements(pipeline, { runs = [], now = Date.now() } = {}) {
112
+ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), observed = null } = {}) {
113
113
  const credit = tallyRuns(runs);
114
114
  const out = [];
115
+ // 前置违规(R1):跨整本账本查一次——某步被执行过,而它的某个 needs 前置
116
+ // 在**同一条 run 里**既没执行也没被跳过。查账本级而不是 run 级,
117
+ // 是因为 tallyRuns 已经把 run 边界抹掉了;这里要保住边界,所以单独扫 runs。
118
+ const violationSeen = new Set();
119
+ for (const run of runs || []) {
120
+ const doneIds = new Set((run.steps || []).filter((s) => s && !s.skipped && !s.detoured).map((s) => s.id));
121
+ for (const step of pipeline.steps) {
122
+ if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue;
123
+ for (const need of step.needs) {
124
+ if (doneIds.has(need)) continue;
125
+ const had = (run.steps || []).some((s) => s && s.id === need);
126
+ if (had) continue; // 前置做过(哪怕是 skipped)就不算违规
127
+ const vk = `${run.at ?? ""}|${step.id}|${need}`;
128
+ if (violationSeen.has(vk)) continue;
129
+ violationSeen.add(vk);
130
+ // 同一步对不同前置各报一条:修图的人需要知道**断在哪条边**上
131
+ out.push({
132
+ kind: "violation-precedence",
133
+ stepId: step.id,
134
+ title: step.title,
135
+ because: `执行过 ${step.id} 的那次(${run.at ?? "无时间"})里,前置 ${need} 既没做也没标跳过`,
136
+ effect: `提议重看 ${need} → ${step.id} 这条依赖——要么顺序写错了,要么前置根本不必要`
137
+ });
138
+ }
139
+ }
140
+ }
115
141
  for (const step of pipeline.steps) {
116
142
  const c = credit.get(step.id);
117
143
  if (!c) continue;
118
- if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
144
+ // R2:跳过的性质由步骤自己的 optional 声明决定,不再靠次数猜意图。
145
+ // 可选步骤跳过 = 设计如此,**永远不提议删**;
146
+ // 必经步骤被跳 = 违规信号,rework-path 与 drop-step **并存**——
147
+ // 同一事实「必经的一步总被跳」既可能是「这步多余」(删)也可能是「流程写错」
148
+ // (修图),判要靠人。这里绝不能用 if/else 吞并:07 号文那条教训在本仓重演过。
149
+ if (step.optional !== true && (c.skipped || 0) >= RETIRE_AFTER_SKIPS && (c.uses || 0) === 0) {
150
+ // 必经、从未真正执行、且总被跳:最像「写进清单但实际从不执行」(08 号文实测
151
+ // gh pr merge --rebase 出现 0 次)。删步与修图两种读法都成立,同时给人。
119
152
  out.push({
120
153
  kind: "drop-step",
121
154
  stepId: step.id,
122
155
  title: step.title,
123
- because: `被跳过 ${c.skipped} 次`,
124
- effect: "提议删掉这一步——对这条流程它可能已经没用了"
156
+ because: `从未被执行过、且被跳过 ${c.skipped} 次`,
157
+ effect: "提议删掉这一步——写进清单但实际从不执行的步骤,最可能是清单过时"
125
158
  });
126
159
  }
160
+ if (step.optional === true) {
161
+ if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
162
+ out.push({
163
+ kind: "optional-skip-ok",
164
+ stepId: step.id,
165
+ title: step.title,
166
+ because: `可选步骤,被跳过 ${c.skipped} 次`,
167
+ effect: "不提议改动——这是 optional 声明的正常行为(信息项,供人留意)"
168
+ });
169
+ }
170
+ } else {
171
+ if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
172
+ out.push({
173
+ kind: "rework-path",
174
+ // cause 区分同 kind 的两种成因:nudge 侧按它选封闭问法——「必经的一步总
175
+ // 被跳」问的是删步/改 optional,「被绕开成常态」问的是重画路径,共用一句
176
+ // 问法会答非所问(CodeRabbit #458 F3)。
177
+ cause: "skipped",
178
+ stepId: step.id,
179
+ title: step.title,
180
+ because: `必经步骤被跳过 ${c.skipped} 次`,
181
+ effect: "提议重看这条路径——必经的一步总被跳,要么它是多余的(该删/改 optional),要么流程写错了"
182
+ });
183
+ }
184
+ }
127
185
  if ((c.detoured || 0) >= RETIRE_AFTER_DETOURS) {
128
186
  out.push({
129
187
  kind: "rework-path",
188
+ cause: "detoured",
130
189
  stepId: step.id,
131
190
  title: step.title,
132
191
  because: `被绕开 ${c.detoured} 次`,
@@ -134,6 +193,31 @@ export function suggestRetirements(pipeline, { runs = [], now = Date.now() } = {
134
193
  });
135
194
  }
136
195
  }
196
+ // P3 投影(fix-step-tool):人确认做过、但声明的 tool 在攒下的观察里从没出现。
197
+ // 观察是旁挂文件里的聚合({key,count},无命令原文),不泄露路径。
198
+ // **不做累计**:一次矛盾就报——人可以判「声明过时/本来不必敲/我记错了」。
199
+ // 纯人判步骤(无 tool 声明)与 optional 步骤不进判据:前者无从矛盾,
200
+ // 后者「这次没做」是正常,谈不上「做了但没看到」。
201
+ if (observed && typeof observed === "object" && observed.latest?.activities?.length) {
202
+ const seen = new Set(observed.latest.activities.map((a) => String(a?.key ?? "").toLowerCase()).filter(Boolean));
203
+ for (const step of pipeline.steps) {
204
+ if (step.optional === true) continue;
205
+ const declared = typeof step.tool === "string" ? step.tool.trim() : "";
206
+ if (declared === "") continue;
207
+ const anchors = declared.split("/").map((t) => t.trim().toLowerCase()).filter(Boolean);
208
+ if (anchors.some((a) => seen.has(a))) continue;
209
+ const c = credit.get(step.id);
210
+ if (!c || (c.uses || 0) === 0) continue; // 没被确认做过 → 无矛盾可言
211
+ out.push({
212
+ kind: "fix-step-tool",
213
+ stepId: step.id,
214
+ title: step.title,
215
+ because: `确认做过 ${c.uses} 次,但声明的 ${declared} 在最近的观察里一次都没出现`,
216
+ effect: "提议核对这个声明——是 tool 写过时了,还是这一步本来就不必敲那条命令",
217
+ observedTop: observed.latest.activities.slice(0, 3).map((a) => `${a.key} ×${a.count}`)
218
+ });
219
+ }
220
+ }
137
221
  return out;
138
222
  }
139
223
 
@@ -214,9 +298,23 @@ export function validatePipeline(raw, file = "<pipeline>") {
214
298
  if (!ACTORS.includes(step.who)) bad(`${at}.who 必须是 ${ACTORS.join(" / ")}`);
215
299
  if (!Array.isArray(step.needs)) bad(`${at}.needs 必须是数组(可空,表示这是一段的入口)`);
216
300
  if (step.gate !== undefined && typeof step.gate !== "boolean") bad(`${at}.gate 只能是布尔`);
301
+ // 可选/必经声明(R2):缺省 = 必经。有了它,「被跳过」才第一次有性质——
302
+ // 可选步骤跳过是正常,必经步骤被跳是违规。见 07 号文的语义对照与 15 号文拍板。
303
+ if (step.optional !== undefined && typeof step.optional !== "boolean") bad(`${at}.optional 只能是布尔`);
217
304
  if (step.evidence !== undefined && !Array.isArray(step.evidence)) {
218
305
  bad(`${at}.evidence 必须是字符串数组`);
219
306
  }
307
+ // 断言(可断言节点):一条一条写「这一步怎么算做完」,且要求**机器能判**。
308
+ // 没有它就等于判断节点——完成与否只能由人认。这不是缺失,是两类节点之一。
309
+ if (step.assert !== undefined) {
310
+ if (!Array.isArray(step.assert)) {
311
+ bad(`${at}.assert 必须是字符串数组`);
312
+ } else {
313
+ step.assert.forEach((entry, i) => {
314
+ if (!isNonEmptyString(entry)) bad(`${at}.assert[${i}] 必须是非空字符串`);
315
+ });
316
+ }
317
+ }
220
318
  }
221
319
 
222
320
  // 先把 id 收集齐再校验引用,否则会按声明顺序报一堆假错。
@@ -254,7 +352,13 @@ export function validatePipeline(raw, file = "<pipeline>") {
254
352
  needs: s.needs.slice(),
255
353
  why: s.why,
256
354
  evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [],
257
- gate: s.gate === true
355
+ // 缺省是空数组而不是 undefined:两类节点的区别用「有没有断言」表达,
356
+ // 用缺键会让下游每处都得先判在不在。
357
+ assert: Array.isArray(s.assert) ? s.assert.slice() : [],
358
+ gate: s.gate === true,
359
+ // 缺省 false(= 必经):显式落键而非缺省缺键——「这步可不可跳」是判据要读的
360
+ // 语义,不是可有可无的装饰。读侧拿到的一律是布尔,不用到处判 undefined。
361
+ optional: s.optional === true
258
362
  }))
259
363
  };
260
364
  }
@@ -375,10 +479,14 @@ function summarizePipeline(pipeline, runs, now) {
375
479
  const states = { candidate: 0, trusted: 0, stale: 0, refuted: 0 };
376
480
  let gates = 0;
377
481
  let contrast = 0;
482
+ let assertable = 0;
378
483
  for (const step of pipeline.steps) {
379
484
  states[deriveState(credit.get(step.id) || null, now)] += 1;
380
485
  if (step.gate) gates += 1;
381
486
  if (step.evidence.length) contrast += 1;
487
+ // 可断言节点数:与 contrast 并列的首屏指标。两者含义不同——contrast 数的是
488
+ // 「写了没有」(内容靠人判),assertable 数的是「机器能不能判」。
489
+ if (step.assert.length) assertable += 1;
382
490
  }
383
491
  return {
384
492
  id: pipeline.id,
@@ -395,6 +503,9 @@ function summarizePipeline(pipeline, runs, now) {
395
503
  gates,
396
504
  // 反事实对照覆盖率:19/19 与 6/19 是两条完全不同的工作流,清单上就该看得出来。
397
505
  contrast,
506
+ // 可断言节点数:机器能自动验完的有几步。「0/19」说明这条流程全靠人判——
507
+ // 那是实话,不是缺陷,但清单上必须看得见。
508
+ assertable,
398
509
  states
399
510
  };
400
511
  }
@@ -428,6 +539,11 @@ export function toPipelineV1(pipeline, { runs = [], now = Date.now(), memoryLink
428
539
  risk: step.risk,
429
540
  state: deriveState(c, now),
430
541
  credit: c ? { uses: c.uses, refuted: c.refuted, skipped: c.skipped || 0, detoured: c.detoured || 0, lastUsedAt: c.lastUsedAt } : null,
542
+ // 可断言节点 vs 判断节点:这是两类节点的**唯一**区分点,也是一张图里
543
+ // 「哪些步骤能被机器盯着」的全部依据。**必须投影成 null 而不是空数组**:
544
+ // 空数组会让「作者没写断言」与「写了但一条都没通过」长得一样,而这两件事
545
+ // 在验收时完全不同(前者是判断节点,后者是失败)。
546
+ assert: step.assert.length ? { checks: step.assert.slice(), count: step.assert.length } : null,
431
547
  // 反事实对照是**准入判据**的载体(「没有它这次会不会失败」),不是备注:
432
548
  // 答不出来的步骤在图上要显得比答得出来的虚。
433
549
  counterfactual: step.evidence.length
@@ -482,7 +598,12 @@ export function readPipelineDoc({
482
598
  id = null,
483
599
  runsById = {},
484
600
  now = Date.now(),
485
- memoryLinks = {}
601
+ memoryLinks = {},
602
+ // P3 投影的燃料:观察旁挂(调用方用 loadObservations 读好后传进来)。
603
+ // 形状:{ [pipelineId]: { latest: { activities: [{key,count,flags?}] } } }。
604
+ // 设计成调用方预读而不是传 memoryDir——pipeline.js 保持零文件系统职责(只读
605
+ // 自己的 pipelines 目录),旁挂的路径规则归 observations 模块管。
606
+ observationsById = {}
486
607
  } = {}) {
487
608
  const sources = pipelineSources({ projectRoot, pluginDir });
488
609
  const ordered = [];
@@ -529,6 +650,6 @@ export function readPipelineDoc({
529
650
  ...toPipelineV1(chosen, { runs: runsById[chosen.id] || [], now, memoryLinks }),
530
651
  ...base,
531
652
  // 图的自我精简:计数到了就把提议冒出来,不用人等巡检。
532
- suggestions: suggestRetirements(chosen, { runs: runsById[chosen.id] || [], now })
653
+ suggestions: suggestRetirements(chosen, { runs: runsById[chosen.id] || [], now, observed: observationsById[chosen.id] ?? null })
533
654
  };
534
655
  }