@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.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 (543) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +830 -828
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -254
  6. package/bin/dsh-mneme-mcp.mjs +510 -510
  7. package/bin/dsh-mneme-serve.mjs +151 -151
  8. package/cordis.patch.yml +21 -21
  9. package/docs/CONFIGURATION.md +407 -402
  10. package/docs/CONTINUITY.md +139 -139
  11. package/docs/DAEMON.md +57 -57
  12. package/docs/DIRECTORIES.md +114 -114
  13. package/docs/DISTILL.md +397 -371
  14. package/docs/DSH-GO.md +97 -97
  15. package/docs/ENTITIES.md +247 -247
  16. package/docs/FORGET.md +77 -77
  17. package/docs/KB.md +110 -110
  18. package/docs/LOCAL_MODEL.md +222 -222
  19. package/docs/SEMANTIC.md +256 -256
  20. package/docs/SHARE.md +552 -552
  21. package/docs/SLEEP.md +202 -202
  22. package/docs/STORAGE.md +122 -122
  23. package/docs/WORKFLOWS.md +366 -366
  24. package/lib/api-standalone.js +1007 -1007
  25. package/lib/api.js +3600 -3394
  26. package/lib/attachment-text.js +122 -122
  27. package/lib/bootstrap-http.js +105 -105
  28. package/lib/bootstrap.js +215 -215
  29. package/lib/candidates.js +454 -454
  30. package/lib/capabilities.js +819 -819
  31. package/lib/choro-command.js +111 -111
  32. package/lib/client.js +14420 -13436
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1317
  36. package/lib/content-hash.js +54 -54
  37. package/lib/continuity.js +258 -258
  38. package/lib/date-hint.js +32 -0
  39. package/lib/diary/auto.js +190 -190
  40. package/lib/diary/compose.js +440 -440
  41. package/lib/diary/dir.js +59 -59
  42. package/lib/diary/export.js +97 -97
  43. package/lib/diary/index.js +26 -26
  44. package/lib/diary/library.js +189 -189
  45. package/lib/diary/qq-inbox.js +142 -142
  46. package/lib/diary/rollover.js +61 -61
  47. package/lib/diary/session-provider.js +122 -122
  48. package/lib/document-index.js +94 -94
  49. package/lib/document.js +322 -322
  50. package/lib/dream/clustering.js +123 -123
  51. package/lib/dream/decisions.js +945 -907
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -1887
  54. package/lib/dream/triage.js +139 -0
  55. package/lib/dream.js +1879 -1764
  56. package/lib/dsh-go/host.js +162 -162
  57. package/lib/dsh-go/panel.js +2091 -2091
  58. package/lib/dsh-go/text.js +342 -342
  59. package/lib/dsh-go/views/common.js +68 -68
  60. package/lib/dsh-go/views/entity.js +48 -48
  61. package/lib/dsh-go/views/memory.js +198 -198
  62. package/lib/dsh-go/views/root.js +30 -30
  63. package/lib/dsh-go/views/settings.js +398 -398
  64. package/lib/dsh-go/views/status.js +51 -51
  65. package/lib/embedding.js +342 -342
  66. package/lib/entities/extractor.js +285 -285
  67. package/lib/fence.js +24 -24
  68. package/lib/graph/anchoring.js +184 -184
  69. package/lib/guide.js +74 -74
  70. package/lib/heat.js +144 -144
  71. package/lib/hot-memory.js +119 -107
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -822
  74. package/lib/inject.js +656 -639
  75. package/lib/kb/index.js +309 -309
  76. package/lib/kb/routes.js +219 -219
  77. package/lib/kb/tools.js +148 -148
  78. package/lib/lang.js +607 -584
  79. package/lib/local-embedder.js +358 -358
  80. package/lib/maintenance.js +162 -162
  81. package/lib/memory-source.js +9 -9
  82. package/lib/mirror.js +330 -330
  83. package/lib/organize.js +456 -456
  84. package/lib/peak-hours.js +172 -172
  85. package/lib/pipeline-activities.js +178 -178
  86. package/lib/pipeline-attribution.js +165 -165
  87. package/lib/pipeline-command.js +279 -279
  88. package/lib/pipeline-confirm.js +142 -142
  89. package/lib/pipeline-exec.js +174 -174
  90. package/lib/pipeline-nudge-auto.js +129 -129
  91. package/lib/pipeline-nudge.js +1623 -1623
  92. package/lib/pipeline-observations.js +181 -181
  93. package/lib/pipeline-observe.js +133 -133
  94. package/lib/pipeline-runner.js +361 -361
  95. package/lib/pipeline-runs.js +442 -442
  96. package/lib/pipeline.js +716 -716
  97. package/lib/placement.js +13 -13
  98. package/lib/pre-inject-gate.js +354 -354
  99. package/lib/profile-extract.js +164 -164
  100. package/lib/profile-facts.js +356 -356
  101. package/lib/provenance-http.js +251 -251
  102. package/lib/quality-filter.js +162 -162
  103. package/lib/recall-stats.js +190 -190
  104. package/lib/reclaim-http.js +150 -150
  105. package/lib/reranker.js +269 -269
  106. package/lib/runtime/adopt.js +176 -176
  107. package/lib/runtime/closure.js +172 -172
  108. package/lib/runtime/download.js +247 -247
  109. package/lib/runtime/layout.js +265 -265
  110. package/lib/runtime/loader.js +252 -252
  111. package/lib/runtime/model-fetch.js +361 -361
  112. package/lib/runtime/provision.js +237 -237
  113. package/lib/runtime/tarball.js +130 -130
  114. package/lib/runtime/verify.js +223 -223
  115. package/lib/safe-slice.js +18 -18
  116. package/lib/scope-state.js +277 -277
  117. package/lib/scope.js +187 -187
  118. package/lib/search/adaptive.js +60 -60
  119. package/lib/search/bm25.js +96 -96
  120. package/lib/semantic.js +572 -572
  121. package/lib/sensitive-scan.js +209 -209
  122. package/lib/serve.js +228 -228
  123. package/lib/service.js +3268 -3196
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -802
  127. package/lib/share-http.js +281 -281
  128. package/lib/share-import.js +234 -234
  129. package/lib/share.js +335 -335
  130. package/lib/store.js +5895 -5662
  131. package/lib/summarize.js +1388 -1343
  132. package/lib/tools.js +1370 -1356
  133. package/lib/user-settings.js +230 -230
  134. package/lib/vector-index.js +180 -180
  135. package/lib/version-check.js +122 -122
  136. package/lib/wiki/claims.js +267 -0
  137. package/lib/wiki/cli-hint.js +90 -90
  138. package/lib/wiki/inject.js +164 -164
  139. package/lib/wiki/partition.js +200 -200
  140. package/lib/wiki/plan.js +214 -214
  141. package/lib/wiki/queries.js +640 -474
  142. package/lib/wiki/render.js +261 -187
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -463
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -502
  147. package/lib/write-admission.js +404 -404
  148. package/locale/en.json +6 -6
  149. package/locale/zh.json +6 -6
  150. package/package.json +15 -1
  151. package/runtime-manifest.json +526 -526
  152. package/scripts/benchmark-embed.js +201 -201
  153. package/scripts/benchmark-recall.js +249 -249
  154. package/scripts/benchmark-rerank.js +166 -166
  155. package/scripts/build-runtime-manifest.mjs +224 -224
  156. package/scripts/changelog-prep.mjs +91 -91
  157. package/scripts/check-sync.js +44 -44
  158. package/scripts/e2e-dsh.js +220 -220
  159. package/scripts/mneme-runtime.mjs +277 -277
  160. package/scripts/publish-gate.mjs +197 -197
  161. package/scripts/release-docs.mjs +210 -204
  162. package/scripts/release-notes.mjs +164 -161
  163. package/scripts/stress-dsh.js +255 -255
  164. package/scripts/sync-lib.js +47 -47
  165. package/scripts/sync-test-badge.mjs +69 -69
  166. package/scripts/test-count-sync.mjs +48 -48
  167. package/src/api-standalone.js +1007 -1007
  168. package/src/api.js +3600 -3394
  169. package/src/attachment-text.js +122 -122
  170. package/src/bootstrap-http.js +105 -105
  171. package/src/bootstrap.js +215 -215
  172. package/src/candidates.js +454 -454
  173. package/src/capabilities.js +819 -819
  174. package/src/choro-command.js +111 -111
  175. package/src/commands.js +95 -95
  176. package/src/config-keys.js +144 -144
  177. package/src/config.js +1361 -1317
  178. package/src/content-hash.js +54 -54
  179. package/src/continuity.js +258 -258
  180. package/src/date-hint.js +32 -0
  181. package/src/diary/auto.js +190 -190
  182. package/src/diary/compose.js +440 -440
  183. package/src/diary/dir.js +59 -59
  184. package/src/diary/export.js +97 -97
  185. package/src/diary/index.js +26 -26
  186. package/src/diary/library.js +189 -189
  187. package/src/diary/qq-inbox.js +142 -142
  188. package/src/diary/rollover.js +61 -61
  189. package/src/diary/session-provider.js +122 -122
  190. package/src/document-index.js +94 -94
  191. package/src/document.js +322 -322
  192. package/src/dream/clustering.js +123 -123
  193. package/src/dream/decisions.js +945 -907
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -1887
  196. package/src/dream/triage.js +139 -0
  197. package/src/dream.js +1879 -1764
  198. package/src/dsh-go/host.js +162 -162
  199. package/src/dsh-go/panel.js +2091 -2091
  200. package/src/dsh-go/text.js +342 -342
  201. package/src/dsh-go/views/common.js +68 -68
  202. package/src/dsh-go/views/entity.js +48 -48
  203. package/src/dsh-go/views/memory.js +198 -198
  204. package/src/dsh-go/views/root.js +30 -30
  205. package/src/dsh-go/views/settings.js +398 -398
  206. package/src/dsh-go/views/status.js +51 -51
  207. package/src/embedding.js +342 -342
  208. package/src/entities/extractor.js +285 -285
  209. package/src/fence.js +24 -24
  210. package/src/graph/anchoring.js +184 -184
  211. package/src/guide.js +74 -74
  212. package/src/heat.js +144 -144
  213. package/src/hot-memory.js +119 -107
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -822
  216. package/src/inject.js +656 -639
  217. package/src/kb/index.js +309 -309
  218. package/src/kb/routes.js +219 -219
  219. package/src/kb/tools.js +148 -148
  220. package/src/lang.js +607 -584
  221. package/src/local-embedder.js +358 -358
  222. package/src/maintenance.js +162 -162
  223. package/src/memory-source.js +9 -9
  224. package/src/mirror.js +330 -330
  225. package/src/organize.js +456 -456
  226. package/src/peak-hours.js +172 -172
  227. package/src/pipeline-activities.js +178 -178
  228. package/src/pipeline-attribution.js +165 -165
  229. package/src/pipeline-command.js +279 -279
  230. package/src/pipeline-confirm.js +142 -142
  231. package/src/pipeline-exec.js +174 -174
  232. package/src/pipeline-nudge-auto.js +129 -129
  233. package/src/pipeline-nudge.js +1623 -1623
  234. package/src/pipeline-observations.js +181 -181
  235. package/src/pipeline-observe.js +133 -133
  236. package/src/pipeline-runner.js +361 -361
  237. package/src/pipeline-runs.js +442 -442
  238. package/src/pipeline.js +716 -716
  239. package/src/placement.js +13 -13
  240. package/src/pre-inject-gate.js +354 -354
  241. package/src/profile-extract.js +164 -164
  242. package/src/profile-facts.js +356 -356
  243. package/src/provenance-http.js +251 -251
  244. package/src/quality-filter.js +162 -162
  245. package/src/recall-stats.js +190 -190
  246. package/src/reclaim-http.js +150 -150
  247. package/src/reranker.js +269 -269
  248. package/src/runtime/adopt.js +176 -176
  249. package/src/runtime/closure.js +172 -172
  250. package/src/runtime/download.js +247 -247
  251. package/src/runtime/layout.js +265 -265
  252. package/src/runtime/loader.js +252 -252
  253. package/src/runtime/model-fetch.js +361 -361
  254. package/src/runtime/provision.js +237 -237
  255. package/src/runtime/tarball.js +130 -130
  256. package/src/runtime/verify.js +223 -223
  257. package/src/safe-slice.js +18 -18
  258. package/src/scope-state.js +277 -277
  259. package/src/scope.js +187 -187
  260. package/src/search/adaptive.js +60 -60
  261. package/src/search/bm25.js +96 -96
  262. package/src/semantic.js +572 -572
  263. package/src/sensitive-scan.js +209 -209
  264. package/src/serve.js +228 -228
  265. package/src/service.js +3268 -3196
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -802
  269. package/src/share-http.js +281 -281
  270. package/src/share-import.js +234 -234
  271. package/src/share.js +335 -335
  272. package/src/store.js +5895 -5662
  273. package/src/summarize.js +1388 -1343
  274. package/src/tools.js +1370 -1356
  275. package/src/user-settings.js +230 -230
  276. package/src/vector-index.js +180 -180
  277. package/src/version-check.js +122 -122
  278. package/src/wiki/claims.js +267 -0
  279. package/src/wiki/cli-hint.js +90 -90
  280. package/src/wiki/inject.js +164 -164
  281. package/src/wiki/partition.js +200 -200
  282. package/src/wiki/plan.js +214 -214
  283. package/src/wiki/queries.js +640 -474
  284. package/src/wiki/render.js +261 -187
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -463
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -502
  289. package/src/write-admission.js +404 -404
  290. package/test/adaptive-inject-budget.test.js +85 -85
  291. package/test/api-hardening.test.js +173 -173
  292. package/test/api-nudge-route.test.js +259 -259
  293. package/test/api-routes-gaps.test.js +1092 -1092
  294. package/test/api.test.js +3007 -2988
  295. package/test/attachment-text.test.js +139 -139
  296. package/test/audit.test.js +448 -448
  297. package/test/baseline-persist.test.js +248 -248
  298. package/test/benchmark.test.js +94 -94
  299. package/test/bm25-corpus-cache.test.js +145 -145
  300. package/test/bootstrap.test.js +228 -228
  301. package/test/candidates.test.js +345 -345
  302. package/test/capabilities.test.js +577 -577
  303. package/test/capacity-warning.test.js +262 -262
  304. package/test/changelog-prep.test.js +125 -125
  305. package/test/choro-command.test.js +218 -218
  306. package/test/cli-enums.test.js +37 -37
  307. package/test/cli-flag-value.test.js +86 -86
  308. package/test/cli-receive.test.js +390 -390
  309. package/test/cli-share.test.js +437 -437
  310. package/test/client-share.test.js +225 -225
  311. package/test/client.test.js +4595 -4184
  312. package/test/clustering.test.js +100 -100
  313. package/test/commands.test.js +106 -106
  314. package/test/config-keys-routes.test.js +233 -233
  315. package/test/config.test.js +153 -153
  316. package/test/conflict-freeze.test.js +344 -344
  317. package/test/conflict-ledger-enforce.test.js +452 -0
  318. package/test/conflict-ledger.test.js +242 -0
  319. package/test/conflict-reviewed.test.js +79 -79
  320. package/test/content-hash.test.js +204 -204
  321. package/test/continuity.test.js +452 -452
  322. package/test/contrast.test.js +432 -320
  323. package/test/date-hint.test.js +156 -0
  324. package/test/dedupe-candidate-window.test.js +180 -180
  325. package/test/dependency-overrides.test.js +78 -78
  326. package/test/diary-library.test.js +187 -187
  327. package/test/diary.test.js +576 -576
  328. package/test/differentiate-feedback.test.js +248 -248
  329. package/test/distill-tx-embed-gap.test.js +110 -110
  330. package/test/docs-config-keys.test.js +65 -65
  331. package/test/document-dir.test.js +233 -233
  332. package/test/document.test.js +703 -703
  333. package/test/dream-archive-guardrail.test.js +116 -116
  334. package/test/dream-baseline-clamp.test.js +174 -0
  335. package/test/dream-candidate-hybrid.test.js +281 -281
  336. package/test/dream-coverage-degrade.test.js +86 -86
  337. package/test/dream-decision-evidence.test.js +314 -314
  338. package/test/dream-failure-backoff.test.js +173 -173
  339. package/test/dream-fence.test.js +207 -207
  340. package/test/dream-maxtokens-fallback.test.js +132 -132
  341. package/test/dream-merge-guardrail.test.js +219 -219
  342. package/test/dream-peak-hours.test.js +194 -194
  343. package/test/dream.test.js +1528 -1528
  344. package/test/dsh-go-host.test.js +134 -134
  345. package/test/dsh-go-panel-actions.test.js +854 -854
  346. package/test/dsh-go-panel.test.js +251 -251
  347. package/test/embed-ready-poll.test.js +94 -94
  348. package/test/embed-text-reasons.test.js +112 -112
  349. package/test/embedding-cache-f32.test.js +276 -276
  350. package/test/embedding-ready.test.js +226 -226
  351. package/test/entities.test.js +711 -711
  352. package/test/entity-recall.test.js +179 -179
  353. package/test/epistemic.test.js +325 -325
  354. package/test/failure-query.test.js +147 -147
  355. package/test/failure-retention.test.js +105 -105
  356. package/test/fnew-0112.test.js +311 -311
  357. package/test/fnew-03.test.js +422 -422
  358. package/test/forget-corrections.test.js +70 -70
  359. package/test/graph-anchoring.test.js +184 -184
  360. package/test/graph-api.test.js +333 -209
  361. package/test/graph-boosted-relations.test.js +189 -189
  362. package/test/graph-inject-budget.test.js +103 -103
  363. package/test/graph-multipath.test.js +269 -269
  364. package/test/graph-passive-confirm.test.js +91 -91
  365. package/test/graph-weight-evolution.test.js +104 -104
  366. package/test/heat.test.js +175 -175
  367. package/test/helpers/dream-mock.js +86 -86
  368. package/test/helpers/peer-worker.mjs +25 -25
  369. package/test/helpers/runtime-source.js +60 -60
  370. package/test/helpers/tar-builder.js +80 -80
  371. package/test/helpers/write-admission-samples.js +312 -312
  372. package/test/hot-memory.test.js +259 -211
  373. package/test/image-description.test.js +271 -0
  374. package/test/image-memory.test.js +170 -170
  375. package/test/inject-content-cap.test.js +91 -91
  376. package/test/inject-first-round-bm25.test.js +89 -89
  377. package/test/inject-heat.test.js +77 -77
  378. package/test/inject-parent-gate.test.js +134 -106
  379. package/test/inject-pin-explicit.test.js +300 -300
  380. package/test/inject-pin-vector-crowd.test.js +225 -225
  381. package/test/inject-pools.test.js +195 -195
  382. package/test/inject-recall.test.js +172 -172
  383. package/test/inject-rotation.test.js +161 -161
  384. package/test/inject.test.js +553 -494
  385. package/test/issue-617-step1-query-and-fusion.test.js +361 -361
  386. package/test/kb-api-wiring.test.js +128 -128
  387. package/test/kb-collections.test.js +272 -272
  388. package/test/kb-routes.test.js +379 -379
  389. package/test/lang.test.js +98 -98
  390. package/test/lastrunat-persist.test.js +101 -101
  391. package/test/lib-smoke.test.js +116 -116
  392. package/test/llm-audit.test.js +815 -815
  393. package/test/llm-message-source.test.js +207 -207
  394. package/test/local-embedder.test.js +292 -292
  395. package/test/maintenance.test.js +501 -501
  396. package/test/mcp-stdio.test.js +385 -385
  397. package/test/mint-source.test.js +311 -311
  398. package/test/mirror-dirty.test.js +424 -424
  399. package/test/mirror-edit-digest.test.js +187 -187
  400. package/test/mirror-generation.test.js +499 -499
  401. package/test/mirror-types.test.js +208 -208
  402. package/test/mirror.test.js +249 -249
  403. package/test/narrative-bars.test.js +162 -162
  404. package/test/organize-tool.test.js +232 -232
  405. package/test/organize.test.js +418 -418
  406. package/test/panel-chart-scale.test.js +90 -0
  407. package/test/panel-tokens.test.js +245 -0
  408. package/test/peak-hours-observability.test.js +70 -70
  409. package/test/peer-blockers.test.js +193 -193
  410. package/test/persona-addressing.test.js +83 -83
  411. package/test/persona-api.test.js +172 -172
  412. package/test/persona-by-session.test.js +568 -568
  413. package/test/persona-flag.test.js +36 -36
  414. package/test/persona-inject.test.js +145 -145
  415. package/test/persona-settings.test.js +198 -198
  416. package/test/pipeline-activities.test.js +155 -155
  417. package/test/pipeline-attribution.test.js +175 -175
  418. package/test/pipeline-command.test.js +243 -243
  419. package/test/pipeline-confirm.test.js +148 -148
  420. package/test/pipeline-exec.test.js +172 -172
  421. package/test/pipeline-nudge-auto.test.js +248 -248
  422. package/test/pipeline-nudge.test.js +1278 -1278
  423. package/test/pipeline-observations.test.js +157 -157
  424. package/test/pipeline-observe.test.js +127 -127
  425. package/test/pipeline-r1r2.test.js +278 -278
  426. package/test/pipeline-runner.test.js +468 -468
  427. package/test/pipeline-runs.test.js +767 -767
  428. package/test/pipeline.test.js +636 -636
  429. package/test/placement.test.js +36 -36
  430. package/test/policy-epoch.test.js +259 -259
  431. package/test/pre-inject-gate.test.js +444 -444
  432. package/test/profile-extract.test.js +269 -269
  433. package/test/profile-facts.test.js +492 -492
  434. package/test/prompt-stance.test.js +128 -128
  435. package/test/provenance-http.test.js +367 -367
  436. package/test/publish-gate.test.js +272 -272
  437. package/test/quality-backfill.test.js +290 -290
  438. package/test/quality-filter.test.js +295 -295
  439. package/test/readme-claims.test.js +113 -113
  440. package/test/reasoning-effort.test.js +752 -752
  441. package/test/recall-anti-update.test.js +82 -82
  442. package/test/recall-evals.test.js +253 -253
  443. package/test/recall-layer.test.js +326 -326
  444. package/test/recall-retention.test.js +128 -128
  445. package/test/recall-stats.test.js +274 -274
  446. package/test/receipt-chain.test.js +451 -451
  447. package/test/reclaim-http.test.js +209 -209
  448. package/test/reflection-update-gate.test.js +129 -129
  449. package/test/reflection.test.js +226 -226
  450. package/test/reindex-backfill.test.js +475 -475
  451. package/test/reindex-scheduler.test.js +369 -369
  452. package/test/release-docs.test.js +156 -136
  453. package/test/release-notes.test.js +265 -240
  454. package/test/release-workflow.test.js +93 -93
  455. package/test/reranker-single-logit.test.js +73 -73
  456. package/test/reranker.test.js +197 -197
  457. package/test/resident-status.test.js +83 -83
  458. package/test/route-policy.test.js +113 -113
  459. package/test/runtime-adopt.test.js +206 -206
  460. package/test/runtime-cli.test.js +89 -89
  461. package/test/runtime-closure.test.js +193 -193
  462. package/test/runtime-download.test.js +302 -302
  463. package/test/runtime-layout.test.js +163 -163
  464. package/test/runtime-loader.test.js +255 -255
  465. package/test/runtime-manifest.test.js +159 -159
  466. package/test/runtime-model-fetch.test.js +348 -348
  467. package/test/runtime-provision.test.js +101 -101
  468. package/test/runtime-tarball.test.js +81 -81
  469. package/test/runtime-verify.test.js +319 -319
  470. package/test/scope-candidates.test.js +491 -491
  471. package/test/scope-distill-stamp.test.js +134 -134
  472. package/test/scope-explicit.test.js +452 -452
  473. package/test/scope-inject-soft.test.js +106 -106
  474. package/test/scope-optional-service.test.js +110 -110
  475. package/test/scope-retrieval.test.js +197 -197
  476. package/test/scope-state.test.js +610 -610
  477. package/test/scope-storage.test.js +255 -255
  478. package/test/scope-strict.test.js +367 -367
  479. package/test/search-fusion.test.js +90 -90
  480. package/test/security-doc.test.js +188 -178
  481. package/test/semantic.test.js +124 -124
  482. package/test/sensitive-scan.test.js +251 -251
  483. package/test/serve-bin.test.js +121 -121
  484. package/test/serve.test.js +258 -258
  485. package/test/service-search.test.js +174 -174
  486. package/test/service.test.js +410 -410
  487. package/test/session-gate.test.js +264 -264
  488. package/test/session-usage.test.js +317 -317
  489. package/test/settings.test.js +273 -273
  490. package/test/share-foreign-rows.test.js +144 -144
  491. package/test/share-http.test.js +144 -144
  492. package/test/share-import.test.js +577 -577
  493. package/test/share-imported-guard.test.js +311 -311
  494. package/test/share-imported-identity.test.js +111 -111
  495. package/test/share-pack.test.js +511 -511
  496. package/test/share-receive-http.test.js +756 -756
  497. package/test/share-routes.test.js +337 -337
  498. package/test/share-store.test.js +133 -133
  499. package/test/share-validate.test.js +101 -101
  500. package/test/skip-invalid-restore.test.js +262 -262
  501. package/test/sleep-action-set.test.js +441 -441
  502. package/test/sleep-catchup.test.js +315 -315
  503. package/test/sleep-failure-backoff.test.js +156 -156
  504. package/test/sleep-heat.test.js +174 -174
  505. package/test/sleep-scheduler-rearm.test.js +89 -89
  506. package/test/sleep.test.js +1455 -1418
  507. package/test/standalone-api.test.js +1344 -1344
  508. package/test/store-vector-cache.test.js +98 -98
  509. package/test/store-write-path-columns.test.js +191 -191
  510. package/test/store.test.js +744 -744
  511. package/test/stress.test.js +209 -209
  512. package/test/summarize-occurred-at.test.js +127 -127
  513. package/test/summarize-parse-diagnostics.test.js +75 -75
  514. package/test/summarize-reasoning-effort.test.js +189 -189
  515. package/test/summarize.test.js +2251 -2251
  516. package/test/supersede-state-tracking.test.js +394 -394
  517. package/test/test-count-sync.test.js +71 -71
  518. package/test/tool-state-ignore.test.js +65 -65
  519. package/test/tools-dto-schema-matrix.test.js +398 -398
  520. package/test/tools-memory-get-schema.test.js +65 -65
  521. package/test/tools.test.js +585 -585
  522. package/test/updated-at-semantics.test.js +113 -113
  523. package/test/user-settings-preview.test.js +127 -127
  524. package/test/user-settings-segments.test.js +527 -527
  525. package/test/vector-index.test.js +334 -334
  526. package/test/version-check.test.js +152 -152
  527. package/test/webServer-optional.test.js +82 -82
  528. package/test/wiki-api.test.js +734 -627
  529. package/test/wiki-claims.test.js +194 -0
  530. package/test/wiki-cli-hint.test.js +118 -118
  531. package/test/wiki-inject.test.js +265 -265
  532. package/test/wiki-partition.test.js +112 -112
  533. package/test/wiki-plan.test.js +80 -80
  534. package/test/wiki-provenance.test.js +202 -0
  535. package/test/wiki-queries.test.js +398 -277
  536. package/test/wiki-scan.test.js +208 -208
  537. package/test/wiki-store.test.js +190 -190
  538. package/test/wiki-t0.test.js +456 -380
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -235
  541. package/test/wiki-tools.test.js +176 -176
  542. package/test/workflow-guards.test.js +155 -155
  543. package/test/write-admission.test.js +668 -668
package/src/pipeline.js CHANGED
@@ -1,716 +1,716 @@
1
- /**
2
- * M.O.(Modus Operandi)工作流的**读侧**。
3
- *
4
- * 本体的落点是仓库(`<项目>/.mneme/pipelines/*.json`),不是记忆库:
5
- * 工作流是要跑的东西,必须能提交、能 PR、能 review、能被 CI 读、能整版回退;
6
- * 记忆库只存「为什么有这条」(证据)与「跑得怎么样」(结算)。
7
- * 三句话的分工:记忆是「为什么」,仓库是「怎么跑」,图是「看起来怎么样」。
8
- *
9
- * 这里只做三件事:读盘 → 校验(宁可读不出来,也不要读出一个半成品)→ 投影成
10
- * `mneme.pipeline/v1`(前端与 agent 共用的那一份结构化数据)。
11
- *
12
- * 为什么不用 YAML:本包运行时依赖只有 `morphicons` 一个,为一个配置文件引 YAML
13
- * 解析器不划算;手写子集解析器则是给未来埋一个「读起来对、跑起来错」的坑。
14
- * 人读的形态由面板渲染负责,不靠文件格式。
15
- *
16
- * **两层来源**(2026-10-15 定):项目层在前、全局层在后,顺序即优先级。
17
- * 一条工作流默认属于某个项目,跟着项目走;装一次插件就在所有项目里看到同一套
18
- * 「全局工作流」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的
19
- * `workspace_scope` 为空,「全局」于是变成谁都不看的杂物堆。全局层留着的唯一
20
- * 理由是接那些**跨项目复用**的工作流,而跨项目复用是一个要留痕的显式动作。
21
- */
22
- import { readFileSync, readdirSync, existsSync } from "node:fs";
23
- import { dirname, join, posix as pathPosix, win32 as pathWin32 } from "node:path";
24
- import { fileURLToPath } from "node:url";
25
-
26
- // 两种路径语义各自指名一次,别让调用点再自己判断平台。
27
- const basenamePosix = pathPosix.basename;
28
- const basenameWin32 = pathWin32.basename;
29
-
30
- /**
31
- * **全局层**的家:插件包自带的 `pipelines/`。算法是「本模块所在目录的上一级」——
32
- * src/ 与 lib/ 都在包根下,所以这个式子对源码树和发布包同时成立。
33
- *
34
- * 它只是**兜底层**,不是默认住处:默认住处是 `projectPipelineDir()`。发布包里今天
35
- * 没有 `pipelines/`(目录为空即不进包),所以全局层通常是空的——**这正是想要的**:
36
- * 一条工作流该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。
37
- */
38
- export function defaultPipelineDir() {
39
- return join(dirname(fileURLToPath(import.meta.url)), "..", "pipelines");
40
- }
41
-
42
- /** **项目层**的家:`<项目>/.mneme/pipelines/*.json`。点开头不碍眼,但仍随仓库提交。 */
43
- export const PROJECT_PIPELINE_SUBDIR = ".mneme";
44
-
45
- export function projectPipelineDir(projectRoot) {
46
- return join(projectRoot, PROJECT_PIPELINE_SUBDIR, "pipelines");
47
- }
48
-
49
- /**
50
- * 项目根 → 面板上那个作用域标签。**只取目录名,不把整条绝对路径画出去**:
51
- * 绝对路径里有本机用户名,而面板是会被截图、被贴进 issue 的那一面。
52
- * 机器要用的完整路径另在 `sources[].dir`,那是数据,不是给人读的。
53
- */
54
- export function projectLabel(projectRoot) {
55
- const root = String(projectRoot ?? "");
56
- // **语义显式指名,不借 path.basename()**:它跟着运行平台走——posix 上 `\` 不是
57
- // 分隔符,一条 Windows 路径会被整条当成标签,而面板正是会被截图、被贴进 issue
58
- // 的那一面,标签里于是带着本机用户名,恰好是本函数要挡住的东西。
59
- // 含反斜杠按 Windows 语义解析,否则按 posix——两个平台的正确结果就此一致。
60
- // Windows 腿对旧写法**结构性看不见**(win32.basename 两种分隔符都认,旧实现在
61
- // 本机算出来就是对的),所以这条的杀伤力只在 ubuntu 矩阵腿上。
62
- const base = root.includes("\\") ? basenameWin32(root) : basenamePosix(root);
63
- return base === "" ? root : base;
64
- }
65
-
66
- /**
67
- * 读取来源,**项目层在前、全局层在后**——顺序就是优先级:同 id 时项目层赢。
68
- *
69
- * 没有项目根(会话工作目录那一刻不可得)时只给全局层,并如实标 `project: null`;
70
- * **绝不回退到进程自己所在的目录**:那会把「读不到」变成「读到别的地方」,
71
- * 而后者在界面上看不出来,是最难查的一类错。
72
- */
73
- export function pipelineSources({ projectRoot = null, pluginDir = defaultPipelineDir() } = {}) {
74
- const sources = [];
75
- const root = typeof projectRoot === "string" ? projectRoot.trim() : "";
76
- if (root !== "") sources.push({ scope: "project", label: projectLabel(root), dir: projectPipelineDir(root) });
77
- sources.push({ scope: "global", label: null, dir: pluginDir });
78
- return sources;
79
- }
80
-
81
- /** 风险四档(R0–R3)。跨档按最高档算;子 agent 只准接 R0 / R1。 */
82
- export const RISK_LEVELS = ["R0", "R1", "R2", "R3"];
83
-
84
- /** 这一步谁做。`human` 与 `ci` 是闸门与流水线自己的位置,不是 agent。 */
85
- export const ACTORS = ["main", "subagent", "human", "ci"];
86
-
87
- /** 工作流状态:草稿 / 生效 / 退役。退役不删文件——留着才知道它曾经存在过。 */
88
- export const STATUSES = ["draft", "active", "retired"];
89
-
90
- /** 节点状态四值;`refuted` 是全篇核心信号。 */
91
- export const NODE_STATES = ["candidate", "trusted", "stale", "refuted"];
92
-
93
- /** 单层最多读多少条。目录被人塞满时宁可报错,也不要把一次面板刷新变成磁盘扫描。 */
94
- const MAX_PIPELINES = 200;
95
-
96
- /**
97
- * 提议的阈值:跳过/绕开多少次就该提一句。
98
- * 3 这个数来自标本 E2/E3——「某步跳过 ≥3 次 → 提议删掉;某动作你手动补过 ≥2 次 → 提议升成正式步骤」。
99
- */
100
- export const RETIRE_AFTER_SKIPS = 3;
101
- export const RETIRE_AFTER_DETOURS = 3;
102
-
103
- /**
104
- * 从运行记录里算出「该提议什么」。这是**图的自我精简**——不是人定期巡检,
105
- * 而是计数到了就冒出来:
106
- * 跳过 ≥3 次 → 提议删掉**这一步**(这一步对这条流程没用了)
107
- * 绕开 ≥3 次 → 提议这条**路径**过时了(从这里岔出去成了常态,图该重画)
108
- *
109
- * 两者的区别是这一版的核心:**跳过 = 这次不用;绕开 = 这条路整个不对。**
110
- * 只有绕开才动「路径」这一层。
111
- */
112
- export function suggestRetirements(pipeline, { runs = [], now = Date.now(), observed = null } = {}) {
113
- const credit = tallyRuns(runs);
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
- // #461:一条 run 的判别键分两类,两类永不相撞。
122
- //
123
- // at 存在(合法账本的唯一形态——normalizeRun 在写侧强制必填)走 `at:<值>`。
124
- // at 缺失(读侧被独立喂进非法形状,或未来写契约放宽)走 `anon:<内容指纹>`:
125
- // by + triggeredBy + 按序的结构化 step 字段(字段取舍见下方 anonymousRunKey)。
126
- // 用**内容**而不是数组索引,是因为
127
- // violationSeen 跨整本账本共享,而索引只在「本次遍历的数组顺序」里唯一——
128
- // 同一条 run 在账本里出现两次时,索引会让它的同一条违规被记成两条,
129
- // 那正是本条要修的那类「键塌缩」的反面形态(#507 评审)。
130
- //
131
- // 评审里希望中间加一层 run.id,但 normalizeRun(src/pipeline-runs.js:187)
132
- // 只产出 { at, by, triggeredBy, steps },没有 id 字段,而 suggestRetirements
133
- // 收到的已经是归一化后的 run;补 id 要改写侧契约,不属这条防御深度问题。
134
- // 内容指纹是同一意图下不需要动 schema 的等价物。
135
- //
136
- // 两类键必须带不同前缀:否则「第 0 条缺 at」与「另一条 at 字面量正好是
137
- // #0」会生成同一个键,后一条 violation-precedence 被静默吞掉。
138
- const runKey = isNonEmptyString(run.at)
139
- ? `at:${run.at}`
140
- : anonymousRunKey(run);
141
- for (const step of pipeline.steps) {
142
- if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue;
143
- for (const need of step.needs) {
144
- if (doneIds.has(need)) continue;
145
- const had = (run.steps || []).some((s) => s && s.id === need);
146
- if (had) continue; // 前置做过(哪怕是 skipped)就不算违规
147
- const vk = `${runKey}|${step.id}|${need}`;
148
- if (violationSeen.has(vk)) continue;
149
- violationSeen.add(vk);
150
- // 同一步对不同前置各报一条:修图的人需要知道**断在哪条边**上
151
- out.push({
152
- kind: "violation-precedence",
153
- stepId: step.id,
154
- title: step.title,
155
- because: `执行过 ${step.id} 的那次(${run.at ?? "无时间"})里,前置 ${need} 既没做也没标跳过`,
156
- effect: `提议重看 ${need} → ${step.id} 这条依赖——要么顺序写错了,要么前置根本不必要`
157
- });
158
- }
159
- }
160
- }
161
- for (const step of pipeline.steps) {
162
- const c = credit.get(step.id);
163
- if (!c) continue;
164
- // R2:跳过的性质由步骤自己的 optional 声明决定,不再靠次数猜意图。
165
- // 可选步骤跳过 = 设计如此,**永远不提议删**;
166
- // 必经步骤被跳 = 违规信号,rework-path 与 drop-step **并存**——
167
- // 同一事实「必经的一步总被跳」既可能是「这步多余」(删)也可能是「流程写错」
168
- // (修图),判要靠人。这里绝不能用 if/else 吞并:07 号文那条教训在本仓重演过。
169
- if (step.optional !== true && (c.skipped || 0) >= RETIRE_AFTER_SKIPS && (c.uses || 0) === 0) {
170
- // 必经、从未真正执行、且总被跳:最像「写进清单但实际从不执行」(08 号文实测
171
- // gh pr merge --rebase 出现 0 次)。删步与修图两种读法都成立,同时给人。
172
- out.push({
173
- kind: "drop-step",
174
- stepId: step.id,
175
- title: step.title,
176
- because: `从未被执行过、且被跳过 ${c.skipped} 次`,
177
- effect: "提议删掉这一步——写进清单但实际从不执行的步骤,最可能是清单过时"
178
- });
179
- }
180
- if (step.optional === true) {
181
- if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
182
- out.push({
183
- kind: "optional-skip-ok",
184
- stepId: step.id,
185
- title: step.title,
186
- because: `可选步骤,被跳过 ${c.skipped} 次`,
187
- effect: "不提议改动——这是 optional 声明的正常行为(信息项,供人留意)"
188
- });
189
- }
190
- } else {
191
- if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
192
- out.push({
193
- kind: "rework-path",
194
- // cause 区分同 kind 的两种成因:nudge 侧按它选封闭问法——「必经的一步总
195
- // 被跳」问的是删步/改 optional,「被绕开成常态」问的是重画路径,共用一句
196
- // 问法会答非所问(CodeRabbit #458 F3)。
197
- cause: "skipped",
198
- stepId: step.id,
199
- title: step.title,
200
- because: `必经步骤被跳过 ${c.skipped} 次`,
201
- effect: "提议重看这条路径——必经的一步总被跳,要么它是多余的(该删/改 optional),要么流程写错了"
202
- });
203
- }
204
- }
205
- if ((c.detoured || 0) >= RETIRE_AFTER_DETOURS) {
206
- out.push({
207
- kind: "rework-path",
208
- cause: "detoured",
209
- stepId: step.id,
210
- title: step.title,
211
- because: `被绕开 ${c.detoured} 次`,
212
- effect: "提议重看这条路径——从这一步岔出去已经成了常态,图该重画"
213
- });
214
- }
215
- }
216
- // P3 投影(fix-step-tool):人确认做过、但声明的 tool 在攒下的观察里从没出现。
217
- // 观察是旁挂文件里的聚合({key,count},无命令原文),不泄露路径。
218
- // **不做累计**:一次矛盾就报——人可以判「声明过时/本来不必敲/我记错了」。
219
- // 纯人判步骤(无 tool 声明)与 optional 步骤不进判据:前者无从矛盾,
220
- // 后者「这次没做」是正常,谈不上「做了但没看到」。
221
- if (observed && typeof observed === "object" && observed.latest?.activities?.length) {
222
- const seen = new Set(observed.latest.activities.map((a) => String(a?.key ?? "").toLowerCase()).filter(Boolean));
223
- for (const step of pipeline.steps) {
224
- if (step.optional === true) continue;
225
- const declared = typeof step.tool === "string" ? step.tool.trim() : "";
226
- if (declared === "") continue;
227
- const anchors = declared.split("/").map((t) => t.trim().toLowerCase()).filter(Boolean);
228
- if (anchors.some((a) => seen.has(a))) continue;
229
- const c = credit.get(step.id);
230
- if (!c || (c.uses || 0) === 0) continue; // 没被确认做过 → 无矛盾可言
231
- out.push({
232
- kind: "fix-step-tool",
233
- stepId: step.id,
234
- title: step.title,
235
- because: `确认做过 ${c.uses} 次,但声明的 ${declared} 在最近的观察里一次都没出现`,
236
- effect: "提议核对这个声明——是 tool 写过时了,还是这一步本来就不必敲那条命令",
237
- observedTop: observed.latest.activities.slice(0, 3).map((a) => `${a.key} ×${a.count}`)
238
- });
239
- }
240
- }
241
- return out;
242
- }
243
-
244
- const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
245
- const STEP_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
246
-
247
- /** 校验失败的统一出口:一次把话说完,别让人一轮修一条。 */
248
- class PipelineError extends Error {
249
- constructor(file, problems) {
250
- super(`${file}: ${problems.join("; ")}`);
251
- this.name = "PipelineError";
252
- this.problems = problems;
253
- }
254
- }
255
-
256
- function isPlainObject(value) {
257
- return !!value && typeof value === "object" && !Array.isArray(value);
258
- }
259
-
260
- function isNonEmptyString(value) {
261
- return typeof value === "string" && value.trim() !== "";
262
- }
263
-
264
- /**
265
- * at 缺失时一条 run 的判别键(#461 / CodeRabbit #507):给无时间戳的 run 一个
266
- * **按内容**的稳定身份,好让 violationSeen 只并掉「同一条 run 重复出现」的同类
267
- * 违规,而不把内容不同的两条 run 并成一条。
268
- *
269
- * 覆盖 normalizeSteps(src/pipeline-runs.js:242)产出的**全部结构化字段**——
270
- * id / at / skipped / detoured / refuted。之所以必须收 step.at:漏了它,两条只在
271
- * 某一步时间上不同的 at-less run 会共键,后一条的 violation-precedence 被去重吞掉
272
- * (#507 指出的正是这条)。
273
- *
274
- * 用 JSON.stringify 而不是手写 `${id}@${at}` 拼接:run 侧 step id 只过
275
- * isSafeSegment(src/pipeline-runs.js:83),`@` `,` `:` 都是合法 id,手写拼接会与
276
- * 「id 里恰好含这些字符」的另一条 step 撞串——那会新造一类键塌缩(把另一类 at-less
277
- * 输入从「不漏报」改成「漏报」)。JSON 自带无歧义分隔,顺带把 by/triggeredBy 里的
278
- * `|`、以及 Date 与 ISO 串(Date.toJSON 归一成 ISO)的歧义一并收掉。
279
- *
280
- * note 不进来:它是自由文本,序列化进去会让「只差备注的同一条观测」被当成两次。
281
- *
282
- * 如实记下代价:skipped/detoured/refuted 对去重正确性是**惰性**的——violation 判定
283
- * 只读 doneIds(由 id + skipped/detoured 决定)与 id 是否存在,refuted 根本不被读
284
- * (见下方 suggestRetirements)。所以收这三个布尔防不住任何错误合并,却会让两条只差
285
- * 这些旗标的 at-less run 各报一条**逐字相同**的提议。仍选择全收,是为了让规则统一
286
- * (指纹 = 归一化 step 的每个结构化字段,唯一例外是自由文本 note),且将来 violation
287
- * 逻辑若开始读某个旗标时不会突然开始漏合并;anon 路径本身没有身份锚点,接受这一条
288
- * 重复的代价。注意这条路径在生产不可达:readStoredRun(src/pipeline-runs.js:262)
289
- * 强制 run.at 存在,正常账本永远走 `at:` 分支,此处纯属直接调用边界的防御深度。
290
- */
291
- function anonymousRunKey(run) {
292
- return `anon:${JSON.stringify({
293
- by: run.by ?? null,
294
- triggeredBy: run.triggeredBy ?? null,
295
- steps: (run.steps || []).map((s) => ({
296
- id: s?.id ?? null,
297
- at: s?.at ?? null,
298
- skipped: !!s?.skipped,
299
- detoured: !!s?.detoured,
300
- refuted: !!s?.refuted
301
- }))
302
- })}`;
303
- }
304
-
305
- /**
306
- * 校验一条工作流。返回归一化后的对象;有任何问题就抛,不返回「部分可用」的东西
307
- * ——半个工作流比没有工作流更危险,因为它会被当成完整的跑。
308
- */
309
- export function validatePipeline(raw, file = "<pipeline>") {
310
- const problems = [];
311
- const bad = (msg) => problems.push(msg);
312
-
313
- if (!isPlainObject(raw)) {
314
- throw new PipelineError(file, ["顶层必须是一个对象"]);
315
- }
316
-
317
- if (!isNonEmptyString(raw.id) || !ID_RE.test(raw.id)) {
318
- bad("id 必须是小写 kebab(如 dsh-mneme-maintenance)");
319
- }
320
- if (!isNonEmptyString(raw.name)) bad("name 必须是字符串");
321
- if (!isNonEmptyString(raw.project)) bad("project 必须是字符串(如 slow-stack/mneme)");
322
- if (!Number.isInteger(raw.version) || raw.version < 0) bad("version 必须是从 0 起的整数");
323
- if (!STATUSES.includes(raw.status)) bad(`status 必须是 ${STATUSES.join(" / ")}`);
324
- if (!isNonEmptyString(raw.done)) bad("done 必须写清「整条怎么算完成」");
325
-
326
- if (!Array.isArray(raw.trigger) || raw.trigger.length === 0) {
327
- bad("trigger 至少要有一条");
328
- } else {
329
- raw.trigger.forEach((t, i) => {
330
- if (!isPlainObject(t) || !isNonEmptyString(t.value)) {
331
- bad(`trigger[${i}] 必须写成 { kind, value }`);
332
- }
333
- });
334
- }
335
-
336
- if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
337
- bad("steps 至少要有一级");
338
- throw new PipelineError(file, problems);
339
- }
340
-
341
- const ids = new Set();
342
- for (const [i, step] of raw.steps.entries()) {
343
- const at = isPlainObject(step) && isNonEmptyString(step.id) ? step.id : `steps[${i}]`;
344
- if (!isPlainObject(step)) {
345
- bad(`${at} 必须是对象`);
346
- continue;
347
- }
348
- if (!isNonEmptyString(step.id) || !STEP_ID_RE.test(step.id)) {
349
- bad(`${at}.id 非法(如 A1 / C2)`);
350
- } else if (ids.has(step.id)) {
351
- bad(`${at}.id 重复`);
352
- } else {
353
- ids.add(step.id);
354
- }
355
- for (const key of ["phase", "title", "when", "do", "done", "why"]) {
356
- if (!isNonEmptyString(step[key])) bad(`${at}.${key} 必须是非空字符串`);
357
- }
358
- if (!RISK_LEVELS.includes(step.risk)) bad(`${at}.risk 必须是 ${RISK_LEVELS.join(" / ")}`);
359
- if (!ACTORS.includes(step.who)) bad(`${at}.who 必须是 ${ACTORS.join(" / ")}`);
360
- if (!Array.isArray(step.needs)) bad(`${at}.needs 必须是数组(可空,表示这是一段的入口)`);
361
- if (step.gate !== undefined && typeof step.gate !== "boolean") bad(`${at}.gate 只能是布尔`);
362
- // 可选/必经声明(R2):缺省 = 必经。有了它,「被跳过」才第一次有性质——
363
- // 可选步骤跳过是正常,必经步骤被跳是违规。见 07 号文的语义对照与 15 号文拍板。
364
- if (step.optional !== undefined && typeof step.optional !== "boolean") bad(`${at}.optional 只能是布尔`);
365
- if (step.evidence !== undefined && !Array.isArray(step.evidence)) {
366
- bad(`${at}.evidence 必须是字符串数组`);
367
- }
368
- // 断言(可断言节点):一条一条写「这一步怎么算做完」,且要求**机器能判**。
369
- // 没有它就等于判断节点——完成与否只能由人认。这不是缺失,是两类节点之一。
370
- if (step.assert !== undefined) {
371
- if (!Array.isArray(step.assert)) {
372
- bad(`${at}.assert 必须是字符串数组`);
373
- } else {
374
- step.assert.forEach((entry, i) => {
375
- if (!isNonEmptyString(entry)) bad(`${at}.assert[${i}] 必须是非空字符串`);
376
- });
377
- }
378
- }
379
- }
380
-
381
- // 先把 id 收集齐再校验引用,否则会按声明顺序报一堆假错。
382
- for (const step of raw.steps) {
383
- if (!Array.isArray(step.needs)) continue;
384
- for (const need of step.needs) {
385
- if (!ids.has(need)) bad(`${step.id}.needs 指向了不存在的步骤 ${need}`);
386
- }
387
- }
388
-
389
- const cycles = findCycles(raw.steps, ids);
390
- if (cycles.length) bad(`图里有环:${cycles.join(" / ")}(回写是新版本,不是回到自己)`);
391
-
392
- if (problems.length) throw new PipelineError(file, problems);
393
-
394
- return {
395
- id: raw.id,
396
- name: raw.name,
397
- project: raw.project,
398
- version: raw.version,
399
- status: raw.status,
400
- done: raw.done,
401
- trigger: raw.trigger.map((t) => ({ kind: t.kind || "phrase", value: t.value, note: t.note || "" })),
402
- inputs: Array.isArray(raw.inputs) ? raw.inputs.slice() : [],
403
- steps: raw.steps.map((s) => ({
404
- id: s.id,
405
- phase: s.phase,
406
- title: s.title,
407
- when: s.when,
408
- do: s.do,
409
- who: s.who,
410
- tool: isNonEmptyString(s.tool) ? s.tool : "",
411
- done: s.done,
412
- risk: s.risk,
413
- needs: s.needs.slice(),
414
- why: s.why,
415
- evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [],
416
- // 缺省是空数组而不是 undefined:两类节点的区别用「有没有断言」表达,
417
- // 用缺键会让下游每处都得先判在不在。
418
- assert: Array.isArray(s.assert) ? s.assert.slice() : [],
419
- gate: s.gate === true,
420
- // 缺省 false(= 必经):显式落键而非缺省缺键——「这步可不可跳」是判据要读的
421
- // 语义,不是可有可无的装饰。读侧拿到的一律是布尔,不用到处判 undefined。
422
- optional: s.optional === true
423
- }))
424
- };
425
- }
426
-
427
- /** 三色 DFS 找环,把环上的步骤 id 拼出来(报错要能直接指到人看的那一行)。 */
428
- function findCycles(steps, ids) {
429
- const needsOf = new Map();
430
- for (const step of steps) {
431
- if (isNonEmptyString(step.id) && Array.isArray(step.needs)) {
432
- needsOf.set(step.id, step.needs.filter((n) => ids.has(n)));
433
- }
434
- }
435
- const state = new Map(); // 0 未访问 / 1 在栈上 / 2 已定
436
- const cycles = [];
437
- const walk = (id, trail) => {
438
- if (state.get(id) === 2) return;
439
- if (state.get(id) === 1) {
440
- cycles.push([...trail.slice(trail.indexOf(id)), id].join(" → "));
441
- return;
442
- }
443
- state.set(id, 1);
444
- for (const next of needsOf.get(id) || []) walk(next, [...trail, id]);
445
- state.set(id, 2);
446
- };
447
- for (const id of needsOf.keys()) walk(id, []);
448
- return cycles;
449
- }
450
-
451
- /**
452
- * 读一个目录下的全部工作流;坏文件直接抛,不做「跳过继续」——静默少一条最难查。
453
- * 每条挂上 `origin`(哪一层来的、家在哪个目录、哪个文件),供上层判优先级与显示。
454
- */
455
- export function loadPipelines(dir, { scope = "global", label = null } = {}) {
456
- if (!existsSync(dir)) return [];
457
- const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
458
- if (files.length > MAX_PIPELINES) {
459
- throw new PipelineError(dir, [`目录里有 ${files.length} 个 .json,超过上限 ${MAX_PIPELINES}——这里应该是工作流目录,不是数据堆`]);
460
- }
461
- const out = [];
462
- const seen = new Map();
463
- for (const file of files) {
464
- const full = join(dir, file);
465
- let raw;
466
- try {
467
- raw = JSON.parse(readFileSync(full, "utf8"));
468
- } catch (error) {
469
- throw new PipelineError(file, [`JSON 解析失败:${error.message}`]);
470
- }
471
- const pipeline = validatePipeline(raw, file);
472
- if (seen.has(pipeline.id)) {
473
- throw new PipelineError(file, [`id "${pipeline.id}" 与 ${seen.get(pipeline.id)} 重复`]);
474
- }
475
- seen.set(pipeline.id, file);
476
- out.push({ ...pipeline, origin: { scope, label, dir, file } });
477
- }
478
- return out;
479
- }
480
-
481
- /**
482
- * 从一条运行记录里数出「这次走过哪几步」——结算用。
483
- *
484
- * 每一步有三种处置,**计数必须分开**,因为它们驱动的判断完全不同:
485
- * uses 照图走了(或走了但被推翻)
486
- * skipped 这一步这次不用(图上还在)→ 跳过 ≥3 次就提议删掉这一步
487
- * detoured 从这里岔出去走了图上没有的路 → 被绕开 ≥3 次说明这条路径过时了
488
- *
489
- * 「跳过」与「绕开」不是一回事:**跳过 = 这次不用这一步;绕开 = 这条路整个不对**。
490
- * 混在一起数,两种提议就都发不出来了。
491
- */
492
- function tallyRuns(runs) {
493
- const perStep = new Map();
494
- for (const run of runs || []) {
495
- for (const step of run.steps || []) {
496
- const cur = perStep.get(step.id) || { uses: 0, refuted: 0, lastUsedAt: null };
497
- perStep.set(step.id, cur);
498
- // 三种处置**各自独立累加**,谁也不吃谁:
499
- // 一次绕行很可能同时是「这一步这次没用」+「从这里岔出去了」——
500
- // 这两件事驱动的提议不同(删这一步 / 重画这条路径),吞掉一个就少一条提议。
501
- // 第一版写成 if/else if,于是绕行把跳过吃掉了。
502
- const detoured = step.detoured === true || step.kind === "detour";
503
- const skipped = step.skipped === true;
504
- if (detoured) cur.detoured = (cur.detoured || 0) + 1;
505
- if (skipped) cur.skipped = (cur.skipped || 0) + 1;
506
- // 只有「既没跳过也没绕行」才算真走了这一步——两种偏离都不是使用。
507
- if (!detoured && !skipped) cur.uses += 1;
508
- if (step.refuted) cur.refuted += 1;
509
- if (step.at && (!cur.lastUsedAt || step.at > cur.lastUsedAt)) cur.lastUsedAt = step.at;
510
- }
511
- }
512
- return perStep;
513
- }
514
-
515
- /**
516
- * 节点状态由**对照数据**推出来,不由作者写死:
517
- * 被推翻过 → refuted;用过没用砸 → trusted;有记录但久未使用 → stale;没有记录 → candidate。
518
- * 没有运行记录时全体是 candidate —— 这是实话,也正是面板上那句 beta 免责必须摆在脸上的原因。
519
- *
520
- * 绕行不改变状态、只累加计数:**被绕开不等于这一步坏了**(可能只是这次不需要),
521
- * 它推动的是「这条路径该重看了」这种**提议**,不是把节点染红。
522
- * 染色要留给真正被推翻过的(refuted)——那是唯一有硬判据的信号。
523
- */
524
- function deriveState(credit, now, staleAfterDays = 90) {
525
- if (!credit || credit.uses === 0) return credit && credit.skipped ? "stale" : "candidate";
526
- if (credit.refuted > 0) return "refuted";
527
- if (credit.lastUsedAt) {
528
- const age = (now - Date.parse(credit.lastUsedAt)) / 86400000;
529
- if (Number.isFinite(age) && age > staleAfterDays) return "stale";
530
- }
531
- return "trusted";
532
- }
533
-
534
- /**
535
- * 清单页要的那点信息:够渲染「名字 + 一句话 + 状态 + 作用域」,不必把整张图读出来。
536
- * 面板第一屏是清单、点进去才是图,所以这一份是**首屏的全部输入**。
537
- */
538
- function summarizePipeline(pipeline, runs, now) {
539
- const credit = tallyRuns(runs);
540
- const states = { candidate: 0, trusted: 0, stale: 0, refuted: 0 };
541
- let gates = 0;
542
- let contrast = 0;
543
- let assertable = 0;
544
- for (const step of pipeline.steps) {
545
- states[deriveState(credit.get(step.id) || null, now)] += 1;
546
- if (step.gate) gates += 1;
547
- if (step.evidence.length) contrast += 1;
548
- // 可断言节点数:与 contrast 并列的首屏指标。两者含义不同——contrast 数的是
549
- // 「写了没有」(内容靠人判),assertable 数的是「机器能不能判」。
550
- if (step.assert.length) assertable += 1;
551
- }
552
- return {
553
- id: pipeline.id,
554
- title: pipeline.name,
555
- scope: pipeline.origin.scope,
556
- scopeLabel: pipeline.origin.label,
557
- project: pipeline.project,
558
- status: pipeline.status,
559
- version: pipeline.version,
560
- done: pipeline.done,
561
- trigger: pipeline.trigger,
562
- file: pipeline.origin.file,
563
- steps: pipeline.steps.length,
564
- gates,
565
- // 反事实对照覆盖率:19/19 与 6/19 是两条完全不同的工作流,清单上就该看得出来。
566
- contrast,
567
- // 可断言节点数:机器能自动验完的有几步。「0/19」说明这条流程全靠人判——
568
- // 那是实话,不是缺陷,但清单上必须看得见。
569
- assertable,
570
- states
571
- };
572
- }
573
-
574
- /**
575
- * 投影成 `mneme.pipeline/v1`:前端渲染与 agent 读取用的是同一份数据,
576
- * 画面只是它的投影——只画不存,agent 就什么也读不到。
577
- */
578
- export function toPipelineV1(pipeline, { runs = [], now = Date.now(), memoryLinks = {} } = {}) {
579
- const credit = tallyRuns(runs);
580
- // 没经过 loadPipelines 的对象(手工 validate 出来的、别处传来的)没有来源。
581
- // 这时 scope 落 null 而不是 "project":默认成项目级会让一条其实来路不明的工作流
582
- // 看起来「就是本项目的」,而面板上没人能分辨。
583
- const origin = pipeline.origin || { scope: null, label: null, dir: "", file: "" };
584
-
585
- const nodes = pipeline.steps.map((step) => {
586
- const c = credit.get(step.id) || null;
587
- const link = memoryLinks[step.id] || null;
588
- return {
589
- id: step.id,
590
- // gate / branch 是画法,不是第二套数据:闸门要能一眼看出来「这里会停下」。
591
- kind: step.gate ? "gate" : step.needs.length > 1 ? "branch" : "step",
592
- title: step.title,
593
- phase: step.phase,
594
- situation: step.when,
595
- action: step.do,
596
- why: step.why,
597
- who: step.who,
598
- tool: step.tool,
599
- done: step.done,
600
- risk: step.risk,
601
- state: deriveState(c, now),
602
- credit: c ? { uses: c.uses, refuted: c.refuted, skipped: c.skipped || 0, detoured: c.detoured || 0, lastUsedAt: c.lastUsedAt } : null,
603
- // 可断言节点 vs 判断节点:这是两类节点的**唯一**区分点,也是一张图里
604
- // 「哪些步骤能被机器盯着」的全部依据。**必须投影成 null 而不是空数组**:
605
- // 空数组会让「作者没写断言」与「写了但一条都没通过」长得一样,而这两件事
606
- // 在验收时完全不同(前者是判断节点,后者是失败)。
607
- assert: step.assert.length ? { checks: step.assert.slice(), count: step.assert.length } : null,
608
- // 反事实对照是**准入判据**的载体(「没有它这次会不会失败」),不是备注:
609
- // 答不出来的步骤在图上要显得比答得出来的虚。
610
- counterfactual: step.evidence.length
611
- ? { contrast: step.evidence.join(" / "), evidence: step.evidence.length }
612
- : null,
613
- // 记忆 id 是本机私有数据,不进仓库文件;有本地对照表才填。
614
- source: link ? { memoryIds: link.memoryIds || [], originSession: link.originSession || null } : null
615
- };
616
- });
617
-
618
- const edges = [];
619
- for (const step of pipeline.steps) {
620
- for (const need of step.needs) {
621
- edges.push({ id: `${need}->${step.id}`, from: need, to: step.id, kind: "control", label: "" });
622
- }
623
- }
624
-
625
- return {
626
- contract: "mneme.pipeline/v1",
627
- pipeline: {
628
- id: pipeline.id,
629
- title: pipeline.name,
630
- // scope 是**从哪儿读出来的**,不是作者写的:同一条文件放进项目层就变项目级。
631
- scope: origin.scope,
632
- scopeLabel: origin.label,
633
- project: pipeline.project,
634
- file: origin.file,
635
- status: pipeline.status,
636
- version: pipeline.version,
637
- done: pipeline.done,
638
- trigger: pipeline.trigger,
639
- inputs: pipeline.inputs,
640
- updatedAt: null
641
- },
642
- nodes,
643
- edges,
644
- runs
645
- };
646
- }
647
-
648
- /**
649
- * 面板与 agent 的公共入口:按来源顺序读全两层,投影出第一条(或指定 id 的那条),
650
- * 并**总是**给出清单(`available`)——首屏是清单,图是点进去才要的东西。
651
- *
652
- * `projectRoot` 是会话工作目录。给不出来时只读全局层,并在 `sources` 里如实标
653
- * `project: null`、`available` 为空:界面据此说「这个会话的工作目录拿不到」,
654
- * 而不是悄悄显示一份不属于任何项目的清单。
655
- */
656
- export function readPipelineDoc({
657
- projectRoot = null,
658
- pluginDir = defaultPipelineDir(),
659
- id = null,
660
- runsById = {},
661
- now = Date.now(),
662
- memoryLinks = {},
663
- // P3 投影的燃料:观察旁挂(调用方用 loadObservations 读好后传进来)。
664
- // 形状:{ [pipelineId]: { latest: { activities: [{key,count,flags?}] } } }。
665
- // 设计成调用方预读而不是传 memoryDir——pipeline.js 保持零文件系统职责(只读
666
- // 自己的 pipelines 目录),旁挂的路径规则归 observations 模块管。
667
- observationsById = {}
668
- } = {}) {
669
- const sources = pipelineSources({ projectRoot, pluginDir });
670
- const ordered = [];
671
- const byId = new Map();
672
- const shadowed = [];
673
- const unreadable = [];
674
-
675
- for (const source of sources) {
676
- let list;
677
- try {
678
- list = loadPipelines(source.dir, { scope: source.scope, label: source.label });
679
- } catch (error) {
680
- // 项目层里有个坏文件,不该把全局层一起拖死:如实记下来继续读下一层。
681
- // 全局层是自家发出去的包,坏了就是坏了——照抛。
682
- if (source.scope === "global") throw error;
683
- unreadable.push({ scope: source.scope, dir: source.dir, detail: error.message });
684
- continue;
685
- }
686
- for (const pipeline of list) {
687
- const already = byId.get(pipeline.id);
688
- if (already) {
689
- // 项目层赢是**安排好的**,不是巧合:记下来,免得以后有人以为丢了文件。
690
- shadowed.push({ id: pipeline.id, hidden: source.scope, by: already.origin.scope });
691
- continue;
692
- }
693
- byId.set(pipeline.id, pipeline);
694
- ordered.push(pipeline);
695
- }
696
- }
697
-
698
- const available = ordered.map((p) => summarizePipeline(p, runsById[p.id] || [], now));
699
- const chosen = (id ? byId.get(id) : ordered[0]) || null;
700
-
701
- const base = {
702
- contract: "mneme.pipeline/v1",
703
- sources: sources.map((s) => ({ scope: s.scope, label: s.label, dir: s.dir, present: existsSync(s.dir) })),
704
- available,
705
- shadowed,
706
- unreadable
707
- };
708
-
709
- if (!chosen) return { ...base, pipeline: null, nodes: [], edges: [], runs: [], suggestions: [] };
710
- return {
711
- ...toPipelineV1(chosen, { runs: runsById[chosen.id] || [], now, memoryLinks }),
712
- ...base,
713
- // 图的自我精简:计数到了就把提议冒出来,不用人等巡检。
714
- suggestions: suggestRetirements(chosen, { runs: runsById[chosen.id] || [], now, observed: observationsById[chosen.id] ?? null })
715
- };
716
- }
1
+ /**
2
+ * M.O.(Modus Operandi)工作流的**读侧**。
3
+ *
4
+ * 本体的落点是仓库(`<项目>/.mneme/pipelines/*.json`),不是记忆库:
5
+ * 工作流是要跑的东西,必须能提交、能 PR、能 review、能被 CI 读、能整版回退;
6
+ * 记忆库只存「为什么有这条」(证据)与「跑得怎么样」(结算)。
7
+ * 三句话的分工:记忆是「为什么」,仓库是「怎么跑」,图是「看起来怎么样」。
8
+ *
9
+ * 这里只做三件事:读盘 → 校验(宁可读不出来,也不要读出一个半成品)→ 投影成
10
+ * `mneme.pipeline/v1`(前端与 agent 共用的那一份结构化数据)。
11
+ *
12
+ * 为什么不用 YAML:本包运行时依赖只有 `morphicons` 一个,为一个配置文件引 YAML
13
+ * 解析器不划算;手写子集解析器则是给未来埋一个「读起来对、跑起来错」的坑。
14
+ * 人读的形态由面板渲染负责,不靠文件格式。
15
+ *
16
+ * **两层来源**(2026-10-15 定):项目层在前、全局层在后,顺序即优先级。
17
+ * 一条工作流默认属于某个项目,跟着项目走;装一次插件就在所有项目里看到同一套
18
+ * 「全局工作流」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的
19
+ * `workspace_scope` 为空,「全局」于是变成谁都不看的杂物堆。全局层留着的唯一
20
+ * 理由是接那些**跨项目复用**的工作流,而跨项目复用是一个要留痕的显式动作。
21
+ */
22
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
23
+ import { dirname, join, posix as pathPosix, win32 as pathWin32 } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ // 两种路径语义各自指名一次,别让调用点再自己判断平台。
27
+ const basenamePosix = pathPosix.basename;
28
+ const basenameWin32 = pathWin32.basename;
29
+
30
+ /**
31
+ * **全局层**的家:插件包自带的 `pipelines/`。算法是「本模块所在目录的上一级」——
32
+ * src/ 与 lib/ 都在包根下,所以这个式子对源码树和发布包同时成立。
33
+ *
34
+ * 它只是**兜底层**,不是默认住处:默认住处是 `projectPipelineDir()`。发布包里今天
35
+ * 没有 `pipelines/`(目录为空即不进包),所以全局层通常是空的——**这正是想要的**:
36
+ * 一条工作流该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。
37
+ */
38
+ export function defaultPipelineDir() {
39
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "pipelines");
40
+ }
41
+
42
+ /** **项目层**的家:`<项目>/.mneme/pipelines/*.json`。点开头不碍眼,但仍随仓库提交。 */
43
+ export const PROJECT_PIPELINE_SUBDIR = ".mneme";
44
+
45
+ export function projectPipelineDir(projectRoot) {
46
+ return join(projectRoot, PROJECT_PIPELINE_SUBDIR, "pipelines");
47
+ }
48
+
49
+ /**
50
+ * 项目根 → 面板上那个作用域标签。**只取目录名,不把整条绝对路径画出去**:
51
+ * 绝对路径里有本机用户名,而面板是会被截图、被贴进 issue 的那一面。
52
+ * 机器要用的完整路径另在 `sources[].dir`,那是数据,不是给人读的。
53
+ */
54
+ export function projectLabel(projectRoot) {
55
+ const root = String(projectRoot ?? "");
56
+ // **语义显式指名,不借 path.basename()**:它跟着运行平台走——posix 上 `\` 不是
57
+ // 分隔符,一条 Windows 路径会被整条当成标签,而面板正是会被截图、被贴进 issue
58
+ // 的那一面,标签里于是带着本机用户名,恰好是本函数要挡住的东西。
59
+ // 含反斜杠按 Windows 语义解析,否则按 posix——两个平台的正确结果就此一致。
60
+ // Windows 腿对旧写法**结构性看不见**(win32.basename 两种分隔符都认,旧实现在
61
+ // 本机算出来就是对的),所以这条的杀伤力只在 ubuntu 矩阵腿上。
62
+ const base = root.includes("\\") ? basenameWin32(root) : basenamePosix(root);
63
+ return base === "" ? root : base;
64
+ }
65
+
66
+ /**
67
+ * 读取来源,**项目层在前、全局层在后**——顺序就是优先级:同 id 时项目层赢。
68
+ *
69
+ * 没有项目根(会话工作目录那一刻不可得)时只给全局层,并如实标 `project: null`;
70
+ * **绝不回退到进程自己所在的目录**:那会把「读不到」变成「读到别的地方」,
71
+ * 而后者在界面上看不出来,是最难查的一类错。
72
+ */
73
+ export function pipelineSources({ projectRoot = null, pluginDir = defaultPipelineDir() } = {}) {
74
+ const sources = [];
75
+ const root = typeof projectRoot === "string" ? projectRoot.trim() : "";
76
+ if (root !== "") sources.push({ scope: "project", label: projectLabel(root), dir: projectPipelineDir(root) });
77
+ sources.push({ scope: "global", label: null, dir: pluginDir });
78
+ return sources;
79
+ }
80
+
81
+ /** 风险四档(R0–R3)。跨档按最高档算;子 agent 只准接 R0 / R1。 */
82
+ export const RISK_LEVELS = ["R0", "R1", "R2", "R3"];
83
+
84
+ /** 这一步谁做。`human` 与 `ci` 是闸门与流水线自己的位置,不是 agent。 */
85
+ export const ACTORS = ["main", "subagent", "human", "ci"];
86
+
87
+ /** 工作流状态:草稿 / 生效 / 退役。退役不删文件——留着才知道它曾经存在过。 */
88
+ export const STATUSES = ["draft", "active", "retired"];
89
+
90
+ /** 节点状态四值;`refuted` 是全篇核心信号。 */
91
+ export const NODE_STATES = ["candidate", "trusted", "stale", "refuted"];
92
+
93
+ /** 单层最多读多少条。目录被人塞满时宁可报错,也不要把一次面板刷新变成磁盘扫描。 */
94
+ const MAX_PIPELINES = 200;
95
+
96
+ /**
97
+ * 提议的阈值:跳过/绕开多少次就该提一句。
98
+ * 3 这个数来自标本 E2/E3——「某步跳过 ≥3 次 → 提议删掉;某动作你手动补过 ≥2 次 → 提议升成正式步骤」。
99
+ */
100
+ export const RETIRE_AFTER_SKIPS = 3;
101
+ export const RETIRE_AFTER_DETOURS = 3;
102
+
103
+ /**
104
+ * 从运行记录里算出「该提议什么」。这是**图的自我精简**——不是人定期巡检,
105
+ * 而是计数到了就冒出来:
106
+ * 跳过 ≥3 次 → 提议删掉**这一步**(这一步对这条流程没用了)
107
+ * 绕开 ≥3 次 → 提议这条**路径**过时了(从这里岔出去成了常态,图该重画)
108
+ *
109
+ * 两者的区别是这一版的核心:**跳过 = 这次不用;绕开 = 这条路整个不对。**
110
+ * 只有绕开才动「路径」这一层。
111
+ */
112
+ export function suggestRetirements(pipeline, { runs = [], now = Date.now(), observed = null } = {}) {
113
+ const credit = tallyRuns(runs);
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
+ // #461:一条 run 的判别键分两类,两类永不相撞。
122
+ //
123
+ // at 存在(合法账本的唯一形态——normalizeRun 在写侧强制必填)走 `at:<值>`。
124
+ // at 缺失(读侧被独立喂进非法形状,或未来写契约放宽)走 `anon:<内容指纹>`:
125
+ // by + triggeredBy + 按序的结构化 step 字段(字段取舍见下方 anonymousRunKey)。
126
+ // 用**内容**而不是数组索引,是因为
127
+ // violationSeen 跨整本账本共享,而索引只在「本次遍历的数组顺序」里唯一——
128
+ // 同一条 run 在账本里出现两次时,索引会让它的同一条违规被记成两条,
129
+ // 那正是本条要修的那类「键塌缩」的反面形态(#507 评审)。
130
+ //
131
+ // 评审里希望中间加一层 run.id,但 normalizeRun(src/pipeline-runs.js:187)
132
+ // 只产出 { at, by, triggeredBy, steps },没有 id 字段,而 suggestRetirements
133
+ // 收到的已经是归一化后的 run;补 id 要改写侧契约,不属这条防御深度问题。
134
+ // 内容指纹是同一意图下不需要动 schema 的等价物。
135
+ //
136
+ // 两类键必须带不同前缀:否则「第 0 条缺 at」与「另一条 at 字面量正好是
137
+ // #0」会生成同一个键,后一条 violation-precedence 被静默吞掉。
138
+ const runKey = isNonEmptyString(run.at)
139
+ ? `at:${run.at}`
140
+ : anonymousRunKey(run);
141
+ for (const step of pipeline.steps) {
142
+ if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue;
143
+ for (const need of step.needs) {
144
+ if (doneIds.has(need)) continue;
145
+ const had = (run.steps || []).some((s) => s && s.id === need);
146
+ if (had) continue; // 前置做过(哪怕是 skipped)就不算违规
147
+ const vk = `${runKey}|${step.id}|${need}`;
148
+ if (violationSeen.has(vk)) continue;
149
+ violationSeen.add(vk);
150
+ // 同一步对不同前置各报一条:修图的人需要知道**断在哪条边**上
151
+ out.push({
152
+ kind: "violation-precedence",
153
+ stepId: step.id,
154
+ title: step.title,
155
+ because: `执行过 ${step.id} 的那次(${run.at ?? "无时间"})里,前置 ${need} 既没做也没标跳过`,
156
+ effect: `提议重看 ${need} → ${step.id} 这条依赖——要么顺序写错了,要么前置根本不必要`
157
+ });
158
+ }
159
+ }
160
+ }
161
+ for (const step of pipeline.steps) {
162
+ const c = credit.get(step.id);
163
+ if (!c) continue;
164
+ // R2:跳过的性质由步骤自己的 optional 声明决定,不再靠次数猜意图。
165
+ // 可选步骤跳过 = 设计如此,**永远不提议删**;
166
+ // 必经步骤被跳 = 违规信号,rework-path 与 drop-step **并存**——
167
+ // 同一事实「必经的一步总被跳」既可能是「这步多余」(删)也可能是「流程写错」
168
+ // (修图),判要靠人。这里绝不能用 if/else 吞并:07 号文那条教训在本仓重演过。
169
+ if (step.optional !== true && (c.skipped || 0) >= RETIRE_AFTER_SKIPS && (c.uses || 0) === 0) {
170
+ // 必经、从未真正执行、且总被跳:最像「写进清单但实际从不执行」(08 号文实测
171
+ // gh pr merge --rebase 出现 0 次)。删步与修图两种读法都成立,同时给人。
172
+ out.push({
173
+ kind: "drop-step",
174
+ stepId: step.id,
175
+ title: step.title,
176
+ because: `从未被执行过、且被跳过 ${c.skipped} 次`,
177
+ effect: "提议删掉这一步——写进清单但实际从不执行的步骤,最可能是清单过时"
178
+ });
179
+ }
180
+ if (step.optional === true) {
181
+ if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
182
+ out.push({
183
+ kind: "optional-skip-ok",
184
+ stepId: step.id,
185
+ title: step.title,
186
+ because: `可选步骤,被跳过 ${c.skipped} 次`,
187
+ effect: "不提议改动——这是 optional 声明的正常行为(信息项,供人留意)"
188
+ });
189
+ }
190
+ } else {
191
+ if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
192
+ out.push({
193
+ kind: "rework-path",
194
+ // cause 区分同 kind 的两种成因:nudge 侧按它选封闭问法——「必经的一步总
195
+ // 被跳」问的是删步/改 optional,「被绕开成常态」问的是重画路径,共用一句
196
+ // 问法会答非所问(CodeRabbit #458 F3)。
197
+ cause: "skipped",
198
+ stepId: step.id,
199
+ title: step.title,
200
+ because: `必经步骤被跳过 ${c.skipped} 次`,
201
+ effect: "提议重看这条路径——必经的一步总被跳,要么它是多余的(该删/改 optional),要么流程写错了"
202
+ });
203
+ }
204
+ }
205
+ if ((c.detoured || 0) >= RETIRE_AFTER_DETOURS) {
206
+ out.push({
207
+ kind: "rework-path",
208
+ cause: "detoured",
209
+ stepId: step.id,
210
+ title: step.title,
211
+ because: `被绕开 ${c.detoured} 次`,
212
+ effect: "提议重看这条路径——从这一步岔出去已经成了常态,图该重画"
213
+ });
214
+ }
215
+ }
216
+ // P3 投影(fix-step-tool):人确认做过、但声明的 tool 在攒下的观察里从没出现。
217
+ // 观察是旁挂文件里的聚合({key,count},无命令原文),不泄露路径。
218
+ // **不做累计**:一次矛盾就报——人可以判「声明过时/本来不必敲/我记错了」。
219
+ // 纯人判步骤(无 tool 声明)与 optional 步骤不进判据:前者无从矛盾,
220
+ // 后者「这次没做」是正常,谈不上「做了但没看到」。
221
+ if (observed && typeof observed === "object" && observed.latest?.activities?.length) {
222
+ const seen = new Set(observed.latest.activities.map((a) => String(a?.key ?? "").toLowerCase()).filter(Boolean));
223
+ for (const step of pipeline.steps) {
224
+ if (step.optional === true) continue;
225
+ const declared = typeof step.tool === "string" ? step.tool.trim() : "";
226
+ if (declared === "") continue;
227
+ const anchors = declared.split("/").map((t) => t.trim().toLowerCase()).filter(Boolean);
228
+ if (anchors.some((a) => seen.has(a))) continue;
229
+ const c = credit.get(step.id);
230
+ if (!c || (c.uses || 0) === 0) continue; // 没被确认做过 → 无矛盾可言
231
+ out.push({
232
+ kind: "fix-step-tool",
233
+ stepId: step.id,
234
+ title: step.title,
235
+ because: `确认做过 ${c.uses} 次,但声明的 ${declared} 在最近的观察里一次都没出现`,
236
+ effect: "提议核对这个声明——是 tool 写过时了,还是这一步本来就不必敲那条命令",
237
+ observedTop: observed.latest.activities.slice(0, 3).map((a) => `${a.key} ×${a.count}`)
238
+ });
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
245
+ const STEP_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
246
+
247
+ /** 校验失败的统一出口:一次把话说完,别让人一轮修一条。 */
248
+ class PipelineError extends Error {
249
+ constructor(file, problems) {
250
+ super(`${file}: ${problems.join("; ")}`);
251
+ this.name = "PipelineError";
252
+ this.problems = problems;
253
+ }
254
+ }
255
+
256
+ function isPlainObject(value) {
257
+ return !!value && typeof value === "object" && !Array.isArray(value);
258
+ }
259
+
260
+ function isNonEmptyString(value) {
261
+ return typeof value === "string" && value.trim() !== "";
262
+ }
263
+
264
+ /**
265
+ * at 缺失时一条 run 的判别键(#461 / CodeRabbit #507):给无时间戳的 run 一个
266
+ * **按内容**的稳定身份,好让 violationSeen 只并掉「同一条 run 重复出现」的同类
267
+ * 违规,而不把内容不同的两条 run 并成一条。
268
+ *
269
+ * 覆盖 normalizeSteps(src/pipeline-runs.js:242)产出的**全部结构化字段**——
270
+ * id / at / skipped / detoured / refuted。之所以必须收 step.at:漏了它,两条只在
271
+ * 某一步时间上不同的 at-less run 会共键,后一条的 violation-precedence 被去重吞掉
272
+ * (#507 指出的正是这条)。
273
+ *
274
+ * 用 JSON.stringify 而不是手写 `${id}@${at}` 拼接:run 侧 step id 只过
275
+ * isSafeSegment(src/pipeline-runs.js:83),`@` `,` `:` 都是合法 id,手写拼接会与
276
+ * 「id 里恰好含这些字符」的另一条 step 撞串——那会新造一类键塌缩(把另一类 at-less
277
+ * 输入从「不漏报」改成「漏报」)。JSON 自带无歧义分隔,顺带把 by/triggeredBy 里的
278
+ * `|`、以及 Date 与 ISO 串(Date.toJSON 归一成 ISO)的歧义一并收掉。
279
+ *
280
+ * note 不进来:它是自由文本,序列化进去会让「只差备注的同一条观测」被当成两次。
281
+ *
282
+ * 如实记下代价:skipped/detoured/refuted 对去重正确性是**惰性**的——violation 判定
283
+ * 只读 doneIds(由 id + skipped/detoured 决定)与 id 是否存在,refuted 根本不被读
284
+ * (见下方 suggestRetirements)。所以收这三个布尔防不住任何错误合并,却会让两条只差
285
+ * 这些旗标的 at-less run 各报一条**逐字相同**的提议。仍选择全收,是为了让规则统一
286
+ * (指纹 = 归一化 step 的每个结构化字段,唯一例外是自由文本 note),且将来 violation
287
+ * 逻辑若开始读某个旗标时不会突然开始漏合并;anon 路径本身没有身份锚点,接受这一条
288
+ * 重复的代价。注意这条路径在生产不可达:readStoredRun(src/pipeline-runs.js:262)
289
+ * 强制 run.at 存在,正常账本永远走 `at:` 分支,此处纯属直接调用边界的防御深度。
290
+ */
291
+ function anonymousRunKey(run) {
292
+ return `anon:${JSON.stringify({
293
+ by: run.by ?? null,
294
+ triggeredBy: run.triggeredBy ?? null,
295
+ steps: (run.steps || []).map((s) => ({
296
+ id: s?.id ?? null,
297
+ at: s?.at ?? null,
298
+ skipped: !!s?.skipped,
299
+ detoured: !!s?.detoured,
300
+ refuted: !!s?.refuted
301
+ }))
302
+ })}`;
303
+ }
304
+
305
+ /**
306
+ * 校验一条工作流。返回归一化后的对象;有任何问题就抛,不返回「部分可用」的东西
307
+ * ——半个工作流比没有工作流更危险,因为它会被当成完整的跑。
308
+ */
309
+ export function validatePipeline(raw, file = "<pipeline>") {
310
+ const problems = [];
311
+ const bad = (msg) => problems.push(msg);
312
+
313
+ if (!isPlainObject(raw)) {
314
+ throw new PipelineError(file, ["顶层必须是一个对象"]);
315
+ }
316
+
317
+ if (!isNonEmptyString(raw.id) || !ID_RE.test(raw.id)) {
318
+ bad("id 必须是小写 kebab(如 dsh-mneme-maintenance)");
319
+ }
320
+ if (!isNonEmptyString(raw.name)) bad("name 必须是字符串");
321
+ if (!isNonEmptyString(raw.project)) bad("project 必须是字符串(如 slow-stack/mneme)");
322
+ if (!Number.isInteger(raw.version) || raw.version < 0) bad("version 必须是从 0 起的整数");
323
+ if (!STATUSES.includes(raw.status)) bad(`status 必须是 ${STATUSES.join(" / ")}`);
324
+ if (!isNonEmptyString(raw.done)) bad("done 必须写清「整条怎么算完成」");
325
+
326
+ if (!Array.isArray(raw.trigger) || raw.trigger.length === 0) {
327
+ bad("trigger 至少要有一条");
328
+ } else {
329
+ raw.trigger.forEach((t, i) => {
330
+ if (!isPlainObject(t) || !isNonEmptyString(t.value)) {
331
+ bad(`trigger[${i}] 必须写成 { kind, value }`);
332
+ }
333
+ });
334
+ }
335
+
336
+ if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
337
+ bad("steps 至少要有一级");
338
+ throw new PipelineError(file, problems);
339
+ }
340
+
341
+ const ids = new Set();
342
+ for (const [i, step] of raw.steps.entries()) {
343
+ const at = isPlainObject(step) && isNonEmptyString(step.id) ? step.id : `steps[${i}]`;
344
+ if (!isPlainObject(step)) {
345
+ bad(`${at} 必须是对象`);
346
+ continue;
347
+ }
348
+ if (!isNonEmptyString(step.id) || !STEP_ID_RE.test(step.id)) {
349
+ bad(`${at}.id 非法(如 A1 / C2)`);
350
+ } else if (ids.has(step.id)) {
351
+ bad(`${at}.id 重复`);
352
+ } else {
353
+ ids.add(step.id);
354
+ }
355
+ for (const key of ["phase", "title", "when", "do", "done", "why"]) {
356
+ if (!isNonEmptyString(step[key])) bad(`${at}.${key} 必须是非空字符串`);
357
+ }
358
+ if (!RISK_LEVELS.includes(step.risk)) bad(`${at}.risk 必须是 ${RISK_LEVELS.join(" / ")}`);
359
+ if (!ACTORS.includes(step.who)) bad(`${at}.who 必须是 ${ACTORS.join(" / ")}`);
360
+ if (!Array.isArray(step.needs)) bad(`${at}.needs 必须是数组(可空,表示这是一段的入口)`);
361
+ if (step.gate !== undefined && typeof step.gate !== "boolean") bad(`${at}.gate 只能是布尔`);
362
+ // 可选/必经声明(R2):缺省 = 必经。有了它,「被跳过」才第一次有性质——
363
+ // 可选步骤跳过是正常,必经步骤被跳是违规。见 07 号文的语义对照与 15 号文拍板。
364
+ if (step.optional !== undefined && typeof step.optional !== "boolean") bad(`${at}.optional 只能是布尔`);
365
+ if (step.evidence !== undefined && !Array.isArray(step.evidence)) {
366
+ bad(`${at}.evidence 必须是字符串数组`);
367
+ }
368
+ // 断言(可断言节点):一条一条写「这一步怎么算做完」,且要求**机器能判**。
369
+ // 没有它就等于判断节点——完成与否只能由人认。这不是缺失,是两类节点之一。
370
+ if (step.assert !== undefined) {
371
+ if (!Array.isArray(step.assert)) {
372
+ bad(`${at}.assert 必须是字符串数组`);
373
+ } else {
374
+ step.assert.forEach((entry, i) => {
375
+ if (!isNonEmptyString(entry)) bad(`${at}.assert[${i}] 必须是非空字符串`);
376
+ });
377
+ }
378
+ }
379
+ }
380
+
381
+ // 先把 id 收集齐再校验引用,否则会按声明顺序报一堆假错。
382
+ for (const step of raw.steps) {
383
+ if (!Array.isArray(step.needs)) continue;
384
+ for (const need of step.needs) {
385
+ if (!ids.has(need)) bad(`${step.id}.needs 指向了不存在的步骤 ${need}`);
386
+ }
387
+ }
388
+
389
+ const cycles = findCycles(raw.steps, ids);
390
+ if (cycles.length) bad(`图里有环:${cycles.join(" / ")}(回写是新版本,不是回到自己)`);
391
+
392
+ if (problems.length) throw new PipelineError(file, problems);
393
+
394
+ return {
395
+ id: raw.id,
396
+ name: raw.name,
397
+ project: raw.project,
398
+ version: raw.version,
399
+ status: raw.status,
400
+ done: raw.done,
401
+ trigger: raw.trigger.map((t) => ({ kind: t.kind || "phrase", value: t.value, note: t.note || "" })),
402
+ inputs: Array.isArray(raw.inputs) ? raw.inputs.slice() : [],
403
+ steps: raw.steps.map((s) => ({
404
+ id: s.id,
405
+ phase: s.phase,
406
+ title: s.title,
407
+ when: s.when,
408
+ do: s.do,
409
+ who: s.who,
410
+ tool: isNonEmptyString(s.tool) ? s.tool : "",
411
+ done: s.done,
412
+ risk: s.risk,
413
+ needs: s.needs.slice(),
414
+ why: s.why,
415
+ evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [],
416
+ // 缺省是空数组而不是 undefined:两类节点的区别用「有没有断言」表达,
417
+ // 用缺键会让下游每处都得先判在不在。
418
+ assert: Array.isArray(s.assert) ? s.assert.slice() : [],
419
+ gate: s.gate === true,
420
+ // 缺省 false(= 必经):显式落键而非缺省缺键——「这步可不可跳」是判据要读的
421
+ // 语义,不是可有可无的装饰。读侧拿到的一律是布尔,不用到处判 undefined。
422
+ optional: s.optional === true
423
+ }))
424
+ };
425
+ }
426
+
427
+ /** 三色 DFS 找环,把环上的步骤 id 拼出来(报错要能直接指到人看的那一行)。 */
428
+ function findCycles(steps, ids) {
429
+ const needsOf = new Map();
430
+ for (const step of steps) {
431
+ if (isNonEmptyString(step.id) && Array.isArray(step.needs)) {
432
+ needsOf.set(step.id, step.needs.filter((n) => ids.has(n)));
433
+ }
434
+ }
435
+ const state = new Map(); // 0 未访问 / 1 在栈上 / 2 已定
436
+ const cycles = [];
437
+ const walk = (id, trail) => {
438
+ if (state.get(id) === 2) return;
439
+ if (state.get(id) === 1) {
440
+ cycles.push([...trail.slice(trail.indexOf(id)), id].join(" → "));
441
+ return;
442
+ }
443
+ state.set(id, 1);
444
+ for (const next of needsOf.get(id) || []) walk(next, [...trail, id]);
445
+ state.set(id, 2);
446
+ };
447
+ for (const id of needsOf.keys()) walk(id, []);
448
+ return cycles;
449
+ }
450
+
451
+ /**
452
+ * 读一个目录下的全部工作流;坏文件直接抛,不做「跳过继续」——静默少一条最难查。
453
+ * 每条挂上 `origin`(哪一层来的、家在哪个目录、哪个文件),供上层判优先级与显示。
454
+ */
455
+ export function loadPipelines(dir, { scope = "global", label = null } = {}) {
456
+ if (!existsSync(dir)) return [];
457
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
458
+ if (files.length > MAX_PIPELINES) {
459
+ throw new PipelineError(dir, [`目录里有 ${files.length} 个 .json,超过上限 ${MAX_PIPELINES}——这里应该是工作流目录,不是数据堆`]);
460
+ }
461
+ const out = [];
462
+ const seen = new Map();
463
+ for (const file of files) {
464
+ const full = join(dir, file);
465
+ let raw;
466
+ try {
467
+ raw = JSON.parse(readFileSync(full, "utf8"));
468
+ } catch (error) {
469
+ throw new PipelineError(file, [`JSON 解析失败:${error.message}`]);
470
+ }
471
+ const pipeline = validatePipeline(raw, file);
472
+ if (seen.has(pipeline.id)) {
473
+ throw new PipelineError(file, [`id "${pipeline.id}" 与 ${seen.get(pipeline.id)} 重复`]);
474
+ }
475
+ seen.set(pipeline.id, file);
476
+ out.push({ ...pipeline, origin: { scope, label, dir, file } });
477
+ }
478
+ return out;
479
+ }
480
+
481
+ /**
482
+ * 从一条运行记录里数出「这次走过哪几步」——结算用。
483
+ *
484
+ * 每一步有三种处置,**计数必须分开**,因为它们驱动的判断完全不同:
485
+ * uses 照图走了(或走了但被推翻)
486
+ * skipped 这一步这次不用(图上还在)→ 跳过 ≥3 次就提议删掉这一步
487
+ * detoured 从这里岔出去走了图上没有的路 → 被绕开 ≥3 次说明这条路径过时了
488
+ *
489
+ * 「跳过」与「绕开」不是一回事:**跳过 = 这次不用这一步;绕开 = 这条路整个不对**。
490
+ * 混在一起数,两种提议就都发不出来了。
491
+ */
492
+ function tallyRuns(runs) {
493
+ const perStep = new Map();
494
+ for (const run of runs || []) {
495
+ for (const step of run.steps || []) {
496
+ const cur = perStep.get(step.id) || { uses: 0, refuted: 0, lastUsedAt: null };
497
+ perStep.set(step.id, cur);
498
+ // 三种处置**各自独立累加**,谁也不吃谁:
499
+ // 一次绕行很可能同时是「这一步这次没用」+「从这里岔出去了」——
500
+ // 这两件事驱动的提议不同(删这一步 / 重画这条路径),吞掉一个就少一条提议。
501
+ // 第一版写成 if/else if,于是绕行把跳过吃掉了。
502
+ const detoured = step.detoured === true || step.kind === "detour";
503
+ const skipped = step.skipped === true;
504
+ if (detoured) cur.detoured = (cur.detoured || 0) + 1;
505
+ if (skipped) cur.skipped = (cur.skipped || 0) + 1;
506
+ // 只有「既没跳过也没绕行」才算真走了这一步——两种偏离都不是使用。
507
+ if (!detoured && !skipped) cur.uses += 1;
508
+ if (step.refuted) cur.refuted += 1;
509
+ if (step.at && (!cur.lastUsedAt || step.at > cur.lastUsedAt)) cur.lastUsedAt = step.at;
510
+ }
511
+ }
512
+ return perStep;
513
+ }
514
+
515
+ /**
516
+ * 节点状态由**对照数据**推出来,不由作者写死:
517
+ * 被推翻过 → refuted;用过没用砸 → trusted;有记录但久未使用 → stale;没有记录 → candidate。
518
+ * 没有运行记录时全体是 candidate —— 这是实话,也正是面板上那句 beta 免责必须摆在脸上的原因。
519
+ *
520
+ * 绕行不改变状态、只累加计数:**被绕开不等于这一步坏了**(可能只是这次不需要),
521
+ * 它推动的是「这条路径该重看了」这种**提议**,不是把节点染红。
522
+ * 染色要留给真正被推翻过的(refuted)——那是唯一有硬判据的信号。
523
+ */
524
+ function deriveState(credit, now, staleAfterDays = 90) {
525
+ if (!credit || credit.uses === 0) return credit && credit.skipped ? "stale" : "candidate";
526
+ if (credit.refuted > 0) return "refuted";
527
+ if (credit.lastUsedAt) {
528
+ const age = (now - Date.parse(credit.lastUsedAt)) / 86400000;
529
+ if (Number.isFinite(age) && age > staleAfterDays) return "stale";
530
+ }
531
+ return "trusted";
532
+ }
533
+
534
+ /**
535
+ * 清单页要的那点信息:够渲染「名字 + 一句话 + 状态 + 作用域」,不必把整张图读出来。
536
+ * 面板第一屏是清单、点进去才是图,所以这一份是**首屏的全部输入**。
537
+ */
538
+ function summarizePipeline(pipeline, runs, now) {
539
+ const credit = tallyRuns(runs);
540
+ const states = { candidate: 0, trusted: 0, stale: 0, refuted: 0 };
541
+ let gates = 0;
542
+ let contrast = 0;
543
+ let assertable = 0;
544
+ for (const step of pipeline.steps) {
545
+ states[deriveState(credit.get(step.id) || null, now)] += 1;
546
+ if (step.gate) gates += 1;
547
+ if (step.evidence.length) contrast += 1;
548
+ // 可断言节点数:与 contrast 并列的首屏指标。两者含义不同——contrast 数的是
549
+ // 「写了没有」(内容靠人判),assertable 数的是「机器能不能判」。
550
+ if (step.assert.length) assertable += 1;
551
+ }
552
+ return {
553
+ id: pipeline.id,
554
+ title: pipeline.name,
555
+ scope: pipeline.origin.scope,
556
+ scopeLabel: pipeline.origin.label,
557
+ project: pipeline.project,
558
+ status: pipeline.status,
559
+ version: pipeline.version,
560
+ done: pipeline.done,
561
+ trigger: pipeline.trigger,
562
+ file: pipeline.origin.file,
563
+ steps: pipeline.steps.length,
564
+ gates,
565
+ // 反事实对照覆盖率:19/19 与 6/19 是两条完全不同的工作流,清单上就该看得出来。
566
+ contrast,
567
+ // 可断言节点数:机器能自动验完的有几步。「0/19」说明这条流程全靠人判——
568
+ // 那是实话,不是缺陷,但清单上必须看得见。
569
+ assertable,
570
+ states
571
+ };
572
+ }
573
+
574
+ /**
575
+ * 投影成 `mneme.pipeline/v1`:前端渲染与 agent 读取用的是同一份数据,
576
+ * 画面只是它的投影——只画不存,agent 就什么也读不到。
577
+ */
578
+ export function toPipelineV1(pipeline, { runs = [], now = Date.now(), memoryLinks = {} } = {}) {
579
+ const credit = tallyRuns(runs);
580
+ // 没经过 loadPipelines 的对象(手工 validate 出来的、别处传来的)没有来源。
581
+ // 这时 scope 落 null 而不是 "project":默认成项目级会让一条其实来路不明的工作流
582
+ // 看起来「就是本项目的」,而面板上没人能分辨。
583
+ const origin = pipeline.origin || { scope: null, label: null, dir: "", file: "" };
584
+
585
+ const nodes = pipeline.steps.map((step) => {
586
+ const c = credit.get(step.id) || null;
587
+ const link = memoryLinks[step.id] || null;
588
+ return {
589
+ id: step.id,
590
+ // gate / branch 是画法,不是第二套数据:闸门要能一眼看出来「这里会停下」。
591
+ kind: step.gate ? "gate" : step.needs.length > 1 ? "branch" : "step",
592
+ title: step.title,
593
+ phase: step.phase,
594
+ situation: step.when,
595
+ action: step.do,
596
+ why: step.why,
597
+ who: step.who,
598
+ tool: step.tool,
599
+ done: step.done,
600
+ risk: step.risk,
601
+ state: deriveState(c, now),
602
+ credit: c ? { uses: c.uses, refuted: c.refuted, skipped: c.skipped || 0, detoured: c.detoured || 0, lastUsedAt: c.lastUsedAt } : null,
603
+ // 可断言节点 vs 判断节点:这是两类节点的**唯一**区分点,也是一张图里
604
+ // 「哪些步骤能被机器盯着」的全部依据。**必须投影成 null 而不是空数组**:
605
+ // 空数组会让「作者没写断言」与「写了但一条都没通过」长得一样,而这两件事
606
+ // 在验收时完全不同(前者是判断节点,后者是失败)。
607
+ assert: step.assert.length ? { checks: step.assert.slice(), count: step.assert.length } : null,
608
+ // 反事实对照是**准入判据**的载体(「没有它这次会不会失败」),不是备注:
609
+ // 答不出来的步骤在图上要显得比答得出来的虚。
610
+ counterfactual: step.evidence.length
611
+ ? { contrast: step.evidence.join(" / "), evidence: step.evidence.length }
612
+ : null,
613
+ // 记忆 id 是本机私有数据,不进仓库文件;有本地对照表才填。
614
+ source: link ? { memoryIds: link.memoryIds || [], originSession: link.originSession || null } : null
615
+ };
616
+ });
617
+
618
+ const edges = [];
619
+ for (const step of pipeline.steps) {
620
+ for (const need of step.needs) {
621
+ edges.push({ id: `${need}->${step.id}`, from: need, to: step.id, kind: "control", label: "" });
622
+ }
623
+ }
624
+
625
+ return {
626
+ contract: "mneme.pipeline/v1",
627
+ pipeline: {
628
+ id: pipeline.id,
629
+ title: pipeline.name,
630
+ // scope 是**从哪儿读出来的**,不是作者写的:同一条文件放进项目层就变项目级。
631
+ scope: origin.scope,
632
+ scopeLabel: origin.label,
633
+ project: pipeline.project,
634
+ file: origin.file,
635
+ status: pipeline.status,
636
+ version: pipeline.version,
637
+ done: pipeline.done,
638
+ trigger: pipeline.trigger,
639
+ inputs: pipeline.inputs,
640
+ updatedAt: null
641
+ },
642
+ nodes,
643
+ edges,
644
+ runs
645
+ };
646
+ }
647
+
648
+ /**
649
+ * 面板与 agent 的公共入口:按来源顺序读全两层,投影出第一条(或指定 id 的那条),
650
+ * 并**总是**给出清单(`available`)——首屏是清单,图是点进去才要的东西。
651
+ *
652
+ * `projectRoot` 是会话工作目录。给不出来时只读全局层,并在 `sources` 里如实标
653
+ * `project: null`、`available` 为空:界面据此说「这个会话的工作目录拿不到」,
654
+ * 而不是悄悄显示一份不属于任何项目的清单。
655
+ */
656
+ export function readPipelineDoc({
657
+ projectRoot = null,
658
+ pluginDir = defaultPipelineDir(),
659
+ id = null,
660
+ runsById = {},
661
+ now = Date.now(),
662
+ memoryLinks = {},
663
+ // P3 投影的燃料:观察旁挂(调用方用 loadObservations 读好后传进来)。
664
+ // 形状:{ [pipelineId]: { latest: { activities: [{key,count,flags?}] } } }。
665
+ // 设计成调用方预读而不是传 memoryDir——pipeline.js 保持零文件系统职责(只读
666
+ // 自己的 pipelines 目录),旁挂的路径规则归 observations 模块管。
667
+ observationsById = {}
668
+ } = {}) {
669
+ const sources = pipelineSources({ projectRoot, pluginDir });
670
+ const ordered = [];
671
+ const byId = new Map();
672
+ const shadowed = [];
673
+ const unreadable = [];
674
+
675
+ for (const source of sources) {
676
+ let list;
677
+ try {
678
+ list = loadPipelines(source.dir, { scope: source.scope, label: source.label });
679
+ } catch (error) {
680
+ // 项目层里有个坏文件,不该把全局层一起拖死:如实记下来继续读下一层。
681
+ // 全局层是自家发出去的包,坏了就是坏了——照抛。
682
+ if (source.scope === "global") throw error;
683
+ unreadable.push({ scope: source.scope, dir: source.dir, detail: error.message });
684
+ continue;
685
+ }
686
+ for (const pipeline of list) {
687
+ const already = byId.get(pipeline.id);
688
+ if (already) {
689
+ // 项目层赢是**安排好的**,不是巧合:记下来,免得以后有人以为丢了文件。
690
+ shadowed.push({ id: pipeline.id, hidden: source.scope, by: already.origin.scope });
691
+ continue;
692
+ }
693
+ byId.set(pipeline.id, pipeline);
694
+ ordered.push(pipeline);
695
+ }
696
+ }
697
+
698
+ const available = ordered.map((p) => summarizePipeline(p, runsById[p.id] || [], now));
699
+ const chosen = (id ? byId.get(id) : ordered[0]) || null;
700
+
701
+ const base = {
702
+ contract: "mneme.pipeline/v1",
703
+ sources: sources.map((s) => ({ scope: s.scope, label: s.label, dir: s.dir, present: existsSync(s.dir) })),
704
+ available,
705
+ shadowed,
706
+ unreadable
707
+ };
708
+
709
+ if (!chosen) return { ...base, pipeline: null, nodes: [], edges: [], runs: [], suggestions: [] };
710
+ return {
711
+ ...toPipelineV1(chosen, { runs: runsById[chosen.id] || [], now, memoryLinks }),
712
+ ...base,
713
+ // 图的自我精简:计数到了就把提议冒出来,不用人等巡检。
714
+ suggestions: suggestRetirements(chosen, { runs: runsById[chosen.id] || [], now, observed: observationsById[chosen.id] ?? null })
715
+ };
716
+ }