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