@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
@@ -88,7 +88,7 @@ test("deleteEmbedding clears the cached vector; getEmbedding reflects it", () =>
88
88
  assert.deepEqual(vectorIndex.getEmbedding(m.id), [0, 0, 1]);
89
89
  });
90
90
 
91
- test("rebuildIndex embeds only rows missing an embedding and marks the model", async () => {
91
+ test("rebuildIndex re-embeds rows with no vector and rows with no provenance", async () => {
92
92
  const { store, vectorIndex } = setup();
93
93
  // One memory already embedded, two missing.
94
94
  addMemory(store, { title: "已有向量", content: "A", vector: [0, 1, 0] });
@@ -101,21 +101,60 @@ test("rebuildIndex embeds only rows missing an embedding and marks the model", a
101
101
  embedder.embedSingle = async (text) => { seen.push(text); return [1, 0, 0]; };
102
102
 
103
103
  const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
104
- assert.equal(result.indexed, 2, "two missing embeddings written");
104
+ // #604: the selector is model-aware, so the row that already carried a vector
105
+ // but no fingerprint (embedding_model NULL) is selected too — without this it
106
+ // stayed permanently unattributed and the boot sweep re-embedded it forever.
107
+ assert.equal(result.indexed, 3, "all three rows written with provenance");
108
+ assert.equal(result.failed, 0);
105
109
  assert.equal(result.skipped, 0);
110
+ assert.equal(result.remaining, 0, "nothing left for this model");
106
111
  assert.equal(vectorIndex.getStats().embeddedCount, 3, "all rows embedded after rebuild");
107
- assert.equal(seen.length, 2, "embedSingle called once per missing row");
112
+ assert.equal(seen.length, 3, "embedSingle called once per selected row");
113
+ assert.equal(store.needsEmbeddingFor("mock#rebuild").length, 0, "provenance recorded, nothing stale");
108
114
 
109
- // Rebuild is a no-op once everything is embedded.
115
+ // Rebuild is a no-op once everything carries this model's fingerprint.
110
116
  const again = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
111
117
  assert.equal(again.indexed, 0);
112
118
  assert.equal(again.skipped, 0);
119
+ assert.equal(again.remaining, 0);
113
120
 
114
121
  // Model fingerprint marked from the rebuild embedder.
115
122
  assert.equal(vectorIndex.modelHash(), "mock#rebuild");
116
123
  assert.equal(vectorIndex.dimension(), 3);
117
124
  });
118
125
 
126
+ test("rebuildIndex picks up rows embedded by a previous model (#604)", async () => {
127
+ const { store, vectorIndex } = setup();
128
+ // Every row already has a vector — from modelA. needsEmbedding() (the old
129
+ // selector) matches only empty vectors, so it would select nothing here and
130
+ // report a successful no-op.
131
+ const a = addMemory(store, { title: "旧模型1", content: "A" });
132
+ const b = addMemory(store, { title: "旧模型2", content: "B" });
133
+ store.setEmbedding(a.id, [0, 1, 0], "modelA#aaa");
134
+ store.setEmbedding(b.id, [0, 1, 0], "modelA#aaa");
135
+ assert.equal(store.needsEmbedding().length, 0, "no row is missing a vector");
136
+ assert.equal(store.needsEmbeddingFor("modelB#bbb").length, 2, "both rows belong to modelA");
137
+
138
+ const embedder = rebuildEmbedder({ vector: [1, 0, 0], modelHash: "modelB#bbb", dimension: 3 });
139
+ const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
140
+
141
+ assert.equal(result.indexed, 2, "model switch is what reindex is for");
142
+ assert.equal(result.remaining, 0);
143
+ assert.equal(store.needsEmbeddingFor("modelB#bbb").length, 0, "provenance now says modelB");
144
+ assert.equal(store.needsEmbeddingFor("modelA#aaa").length, 2, "modelA's vectors are the stale ones now");
145
+ });
146
+
147
+ test("rebuildIndex reports remaining work when the limit truncates the batch", async () => {
148
+ const { store, vectorIndex } = setup();
149
+ for (let i = 0; i < 5; i++) addMemory(store, { title: `行${i}`, content: `C${i}` });
150
+
151
+ const embedder = rebuildEmbedder({ vector: [1, 0, 0], modelHash: "mock#limit", dimension: 3 });
152
+ const result = await vectorIndex.rebuildIndex(embedder, { limit: 2 });
153
+
154
+ assert.equal(result.indexed, 2, "limit bounds the batch");
155
+ assert.equal(result.remaining, 3, "remaining counts what the same predicate still selects");
156
+ });
157
+
119
158
  test("rebuildIndex handles a throwing embedder without failing the batch", async () => {
120
159
  const { store, vectorIndex } = setup();
121
160
  addMemory(store, { title: "好行", content: "A" });
@@ -133,7 +172,11 @@ test("rebuildIndex handles a throwing embedder without failing the batch", async
133
172
 
134
173
  const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
135
174
  assert.equal(result.indexed, 1, "only the successful row is indexed");
136
- assert.equal(result.skipped, 2, "thrown + empty rows count as skipped");
175
+ // #604: a thrown embed and an empty vector are failures, not "skipped" — the
176
+ // old single bucket made them indistinguishable from "did not fit the limit".
177
+ assert.equal(result.failed, 2, "thrown + empty rows count as failures");
178
+ assert.equal(result.skipped, 0, "nothing was skipped for lack of room");
179
+ assert.equal(result.remaining, 2, "failed rows stay selected");
137
180
  assert.equal(vectorIndex.getStats().embeddedCount, 1);
138
181
  });
139
182
 
@@ -142,7 +185,11 @@ test("rebuildIndex guard: embedder with no embed method at all is ignored", asyn
142
185
  addMemory(store, { title: "缺向量", content: "A" });
143
186
  const bare = { modelHash: "mock#bare", dimension: 3 };
144
187
  const result = await vectorIndex.rebuildIndex(bare, { limit: 100 });
145
- assert.deepEqual(result, { indexed: 0, skipped: 0 }, "guard returns early when neither embed nor embedSingle is available");
188
+ assert.deepEqual(
189
+ result,
190
+ { indexed: 0, failed: 0, skipped: 0, remaining: 0, error: "no-embedder" },
191
+ "guard returns early when neither embed nor embedSingle is available"
192
+ );
146
193
  assert.equal(vectorIndex.modelHash(), undefined, "no model marked");
147
194
  });
148
195
 
@@ -219,3 +266,70 @@ test("search returns empty when nothing is embedded", () => {
219
266
  addMemory(store, { title: "也无向量", content: "B" });
220
267
  assert.deepEqual(vectorIndex.search([1, 0, 0], { limit: 10 }), []);
221
268
  });
269
+
270
+ test("rebuildIndex prefers a detailed adapter so a provider outage is not reported as a dead row (#604)", async () => {
271
+ const { store, vectorIndex } = setup();
272
+ addMemory(store, { title: "甲", content: "A" });
273
+ addMemory(store, { title: "乙", content: "B" });
274
+
275
+ let detailedCalls = 0;
276
+ let plainCalls = 0;
277
+ const embedder = {
278
+ // A 503 from the provider: transient, and only the detailed path can say so.
279
+ embedSingleDetailed: async () => {
280
+ detailedCalls++;
281
+ return { vector: null, reason: "http-503" };
282
+ },
283
+ embedSingle: async () => {
284
+ plainCalls++;
285
+ return null;
286
+ },
287
+ modelHash: "mock#detailed",
288
+ dimension: 3
289
+ };
290
+
291
+ const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
292
+ assert.equal(detailedCalls, 2, "every row went through the detailed entry point");
293
+ assert.equal(plainCalls, 0, "the reason-dropping path was not used");
294
+ assert.equal(result.indexed, 0);
295
+ assert.equal(result.failed, 2);
296
+ assert.deepEqual(
297
+ result.failureReasons,
298
+ { "error:http-503": 2 },
299
+ "the button path keeps the provider reason instead of flattening it"
300
+ );
301
+ });
302
+
303
+ test("rebuildIndex separates a permanent row from a transient one on the same run (#604)", async () => {
304
+ const { store, vectorIndex } = setup();
305
+ addMemory(store, { title: "甲", content: "A" });
306
+ addMemory(store, { title: "乙", content: "B" });
307
+
308
+ let calls = 0;
309
+ const embedder = {
310
+ embedSingleDetailed: async () => {
311
+ calls++;
312
+ // First row cannot ever embed; second row is a provider hiccup.
313
+ return calls === 1 ? { vector: null, reason: "response-no-vector" } : { vector: null, reason: "http-503" };
314
+ },
315
+ modelHash: "mock#mixed",
316
+ dimension: 3
317
+ };
318
+
319
+ const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
320
+ assert.equal(result.failed, 2);
321
+ assert.deepEqual(result.failureReasons, {
322
+ "error:response-no-vector": 1,
323
+ "error:http-503": 1
324
+ });
325
+ });
326
+
327
+ test("rebuildIndex still names the plain path's failure when no detailed adapter exists (#604)", async () => {
328
+ const { store, vectorIndex } = setup();
329
+ addMemory(store, { title: "甲", content: "A" });
330
+
331
+ const embedder = { embedSingle: async () => null, modelHash: "mock#plain", dimension: 3 };
332
+ const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
333
+ assert.equal(result.failed, 1);
334
+ assert.deepEqual(result.failureReasons, { "empty-vector": 1 });
335
+ });
@@ -5,7 +5,7 @@
5
5
  import test from "node:test";
6
6
  import assert from "node:assert/strict";
7
7
  import { EventEmitter } from "node:events";
8
- import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
8
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { execFileSync } from "node:child_process";
@@ -120,6 +120,11 @@ test("快乐路径:repos / modules / page / search 全通", async () => {
120
120
  assert.equal(modules.status, 200);
121
121
  assert.ok(modules.body.modules.length >= 2);
122
122
  assert.ok(Array.isArray(modules.body.edges));
123
+ // 面板的地志页导入要把「根路径 + 页相对路径」拼成磁盘绝对路径(契约 §3.6),
124
+ // 回执里缺 relPath 时它会静默回落到「请先选模块」——这条锁的就是那个真机 bug。
125
+ const withPage = modules.body.modules.filter((m) => m.page_rel_path);
126
+ assert.ok(withPage.length >= 1, "fixture 里至少一个模块有骨架页");
127
+ for (const m of withPage) assert.equal(m.relPath, m.page_rel_path);
123
128
 
124
129
  const page = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}&slug=src`);
125
130
  assert.equal(page.status, 200);
@@ -271,3 +276,460 @@ test("/choro/search:embedder 在场 → 惰性物化 + semantic 数组;缺
271
276
  rmTempBestEffort(root);
272
277
  }
273
278
  });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // 契约 §3.2(kb-qoder parity):面板闭环要的新字段与新入口
282
+ // ---------------------------------------------------------------------------
283
+
284
+ test("/choro/repos:契约字段齐备,git 漂移 → stale/git,plan 漂移 → stale/plan", async () => {
285
+ const store = createStore(":memory:");
286
+ const root = makeRepo(store);
287
+ try {
288
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
289
+ const url = "/api/dsh-mneme/choro/repos";
290
+
291
+ // 刚重建完:不该有任何过期信号——否则面板会对着一个刚生成的地志喊「代码已变」,
292
+ // 用户第一次用就学会不信任那个标记。
293
+ const fresh = await invoke(handler(routes, url), url);
294
+ assert.equal(fresh.status, 200);
295
+ const a = fresh.body.repos[0];
296
+ for (const key of ["id", "name", "rootPath", "modules", "pages", "claims", "legacy", "stale", "staleReason", "lastRunAt"]) {
297
+ assert.ok(key in a, `契约字段 ${key} 必须在场(面板按它渲染)`);
298
+ }
299
+ assert.equal(a.stale, false);
300
+ assert.equal(a.staleReason, null);
301
+ assert.equal(a.legacy, false);
302
+ assert.equal(typeof a.lastRunAt, "string");
303
+ assert.ok(a.modules >= 2 && a.pages >= 2, "modules/pages 是契约字段名(不是行里的 module_count)");
304
+
305
+ // 代码变了(新 commit)→ 说过期,理由必须是 git:面板那句话是「代码已变」。
306
+ writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
307
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
308
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
309
+ const drifted = await invoke(handler(routes, url), url);
310
+ assert.equal(drifted.body.repos[0].stale, true);
311
+ assert.equal(drifted.body.repos[0].staleReason, "git");
312
+
313
+ // 先重建一次把 git 基线推平(否则 git 那一路永远优先,plan 判据根本轮不到),
314
+ // 再只改 plan.yaml(HEAD 不动)→ 理由必须是 plan:这条判据不依赖 git,不能被
315
+ // git 那一路掩盖掉,否则「改了模板配置但没提交」的仓库永远显示干净。
316
+ runChoroT0({ repoRoot: root, store, apply: true });
317
+ const cleaned = await invoke(handler(routes, url), url);
318
+ assert.equal(cleaned.body.repos[0].stale, false, "重建后 git 基线推平,过期信号应清掉");
319
+ writeFileSync(join(root, ".chorography", "plan.yaml"), "pages: []\n", "utf8");
320
+ const planDrift = await invoke(handler(routes, url), url);
321
+ assert.equal(planDrift.body.repos[0].stale, true);
322
+ assert.equal(planDrift.body.repos[0].staleReason, "plan");
323
+ } finally {
324
+ rmTempBestEffort(root);
325
+ }
326
+ });
327
+
328
+ test("/choro/run:空态按绝对路径登记新仓库,重复路径复用同一 id", async () => {
329
+ const store = createStore(":memory:");
330
+ // 只把仓库目录准备好但不登记:这就是面板「一个仓库都没登记」的真实起点。
331
+ const root = mkdtempSync(join(tmpdir(), "choro-empty-"));
332
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "emptyfix" }, null, 2) + "\n", "utf8");
333
+ mkdirSync(join(root, "src"), { recursive: true });
334
+ writeFileSync(join(root, "src", "a.js"), "export const a = 1;\n", "utf8");
335
+ execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
336
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
337
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
338
+ try {
339
+ const { routes } = setup({
340
+ config: { chorographyEnabled: true },
341
+ choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
342
+ });
343
+ const route = handler(routes, "/api/dsh-mneme/choro/run");
344
+ const reposUrl = "/api/dsh-mneme/choro/repos";
345
+ const before = await invoke(handler(routes, reposUrl), reposUrl);
346
+ assert.equal(before.body.repos.length, 0, "起点确实是一个仓库都没登记");
347
+
348
+ const first = await invokePost(route, "/api/dsh-mneme/choro/run", { path: root });
349
+ assert.equal(first.status, 200);
350
+ assert.equal(first.body.ok, true);
351
+ assert.ok(first.body.receipt.moduleCount >= 1, "走的是真 T0(有划分回执)");
352
+ const after = await invoke(handler(routes, reposUrl), reposUrl);
353
+ assert.equal(after.body.repos.length, 1, "空态 + 路径 → 自动登记");
354
+
355
+ // 第二次同一路径:不能变成两条记录,也不能报错——面板按钮会被连点。
356
+ const again = await invokePost(route, "/api/dsh-mneme/choro/run", { rootPath: root, refresh: true });
357
+ assert.equal(again.status, 200);
358
+ assert.equal(again.body.refresh, true, "refresh 原样回显,面板据此区分「我要求过重跑」");
359
+ assert.equal((await invoke(handler(routes, reposUrl), reposUrl)).body.repos.length, 1, "同一路径复用既有 id");
360
+ } finally {
361
+ rmTempBestEffort(root);
362
+ }
363
+ });
364
+
365
+ test("/choro/run:路径给错时结构化报错,不落 500 一锅烩", async () => {
366
+ const store = createStore(":memory:");
367
+ const notGit = mkdtempSync(join(tmpdir(), "choro-notgit-"));
368
+ try {
369
+ const { routes } = setup({
370
+ config: { chorographyEnabled: true },
371
+ choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
372
+ });
373
+ const route = handler(routes, "/api/dsh-mneme/choro/run");
374
+
375
+ const rel = await invokePost(route, "/api/dsh-mneme/choro/run", { path: "some/rel/dir" });
376
+ assert.equal(rel.status, 400);
377
+ assert.equal(rel.body.error, "path-not-absolute", "相对路径的语义取决于宿主 cwd,必须挡");
378
+
379
+ const missing = await invokePost(route, "/api/dsh-mneme/choro/run", { path: join(notGit, "nope") });
380
+ assert.equal(missing.status, 400);
381
+ assert.equal(missing.body.error, "path-not-found");
382
+
383
+ const plain = await invokePost(route, "/api/dsh-mneme/choro/run", { path: notGit });
384
+ assert.equal(plain.status, 409);
385
+ assert.equal(plain.body.error, "not-a-git-repo", "目录在但不是 git 仓库 → 409,不是 500");
386
+ } finally {
387
+ rmTempBestEffort(notGit);
388
+ }
389
+ });
390
+
391
+ // T1 会真花钱(模型调用),所以这里注入假的 startT1Job / t1JobStatus:
392
+ // 路由层要锁的只是闸门与 200/409/404 的分叉形状,不是进程本身。
393
+ function withT1({ startT1Job, t1JobStatus } = {}) {
394
+ const store = createStore(":memory:");
395
+ const root = makeRepo(store);
396
+ const choro = { ...createChoroQueries({ store }) };
397
+ if (startT1Job) choro.startT1Job = startT1Job;
398
+ if (t1JobStatus) choro.t1JobStatus = t1JobStatus;
399
+ return { store, root, choro };
400
+ }
401
+
402
+ test("/choro/t1:缺装配 503 / 面板闸关 409 / 同仓并发 409 job-running / 缺省 404", async () => {
403
+ const a = withT1({});
404
+ try {
405
+ const noJobs = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: a.choro });
406
+ const missing = await invokePost(handler(noJobs.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
407
+ assert.equal(missing.status, 503, "没装配任务账 → 503 环境缺件");
408
+ assert.equal(missing.body.error, "unavailable");
409
+ } finally {
410
+ rmTempBestEffort(a.root);
411
+ }
412
+
413
+ const b = withT1({ startT1Job: () => ({ started: true, jobId: "j-1" }) });
414
+ try {
415
+ const off = setup({
416
+ config: { chorographyEnabled: true, chorographyT1FromPanel: false },
417
+ choro: b.choro
418
+ });
419
+ const disabled = await invokePost(handler(off.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
420
+ assert.equal(disabled.status, 409, "T1 花模型额度,面板闸关着就不许起");
421
+ assert.equal(disabled.body.error, "disabled");
422
+
423
+ const on = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: b.choro });
424
+ const t1Route = handler(on.routes, "/api/dsh-mneme/choro/t1");
425
+ assert.equal(t1Route.policy, "token-write", "起一个会写页面/claims 的后台任务属数据面写");
426
+
427
+ const ok = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 1, module: "src", retryFailed: true });
428
+ assert.equal(ok.status, 200);
429
+ assert.deepEqual(ok.body, { jobId: "j-1", started: true });
430
+
431
+ const unknown = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 99 });
432
+ assert.equal(unknown.status, 404);
433
+
434
+ const busy = withT1({ startT1Job: () => ({ started: false, code: "job-running", jobId: "j-2" }) });
435
+ try {
436
+ const busySetup = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: busy.choro });
437
+ const running = await invokePost(handler(busySetup.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
438
+ assert.equal(running.status, 409);
439
+ assert.equal(running.body.error, "job-running");
440
+ assert.equal(running.body.jobId, "j-2", "把在跑的那个 jobId 带回给面板,好让它接着轮询而不是重起");
441
+ } finally {
442
+ rmTempBestEffort(busy.root);
443
+ }
444
+ } finally {
445
+ rmTempBestEffort(b.root);
446
+ }
447
+ });
448
+
449
+ test("/choro/t1/status:jobId 缺 400、未知 404、已知 200 带契约字段", async () => {
450
+ const job = {
451
+ jobId: "j-9",
452
+ repo: { id: 1, name: "x", rootPath: "/x" },
453
+ state: "running",
454
+ startedAt: "T0",
455
+ modules: { total: 2, done: 1, failed: 0 },
456
+ logTail: ["line"]
457
+ };
458
+ const f = withT1({ startT1Job: () => ({ started: true, jobId: "j-9" }), t1JobStatus: (id) => (id === "j-9" ? job : null) });
459
+ try {
460
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: f.choro });
461
+ const route = handler(routes, "/api/dsh-mneme/choro/t1/status");
462
+
463
+ const noId = await invoke(route, "/api/dsh-mneme/choro/t1/status");
464
+ assert.equal(noId.status, 400);
465
+ assert.equal(noId.body.error, "jobId-required");
466
+
467
+ const gone = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=nope");
468
+ assert.equal(gone.status, 404, "宿主重启后旧 jobId 必然查不到,面板据此回到「没有在跑的任务」");
469
+
470
+ const found = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=j-9");
471
+ assert.equal(found.status, 200);
472
+ assert.equal(found.body.state, "running");
473
+ assert.equal(found.body.modules.total, 2);
474
+ assert.deepEqual(found.body.logTail, ["line"]);
475
+ } finally {
476
+ rmTempBestEffort(f.root);
477
+ }
478
+ });
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // chorographyAutoRefresh:读侧自动重跑 T0(面板文案已经把它当契约写死了:
482
+ // 「读地志页时如果发现仓库代码变了,自动重跑一次 T0 骨架」)。
483
+ // 这一批同时锁「开关关着绝不偷偷改用户工作区」与「开关开着真的会刷」——只锁一端
484
+ // 的测试在实现成「永远不刷」或「永远刷」时都会绿,等于没锁。
485
+ // ---------------------------------------------------------------------------
486
+
487
+ // 计数外壳:真实 queries + 包一层的 runT0Apply(记调用次数、可注入失败)。
488
+ // 不造一个假 T0——「刷了但基线没推进」这种 bug 只有真 T0 才照得出来。
489
+ function countingChoro(store, { throwOnRun = false } = {}) {
490
+ const calls = [];
491
+ const base = createChoroQueries({ store });
492
+ return {
493
+ calls,
494
+ choro: {
495
+ ...base,
496
+ // guardT1Loss 必须原样透给真 T0:这条守卫的意义就在于「自动刷新那条路会拒绝」,
497
+ // 外壳把它吞掉就等于测了一个不存在的实现。
498
+ runT0Apply: ({ repoRoot, guardT1Loss = false }) => {
499
+ calls.push(repoRoot);
500
+ if (throwOnRun) throw new Error("磁盘只读(模拟)");
501
+ return runChoroT0({ repoRoot, store, apply: true, guardT1Loss });
502
+ }
503
+ }
504
+ };
505
+ }
506
+
507
+ // 造一个「代码已变」的仓库:登记时的 git_baseline 与当前 HEAD 不同。
508
+ function driftRepo(store) {
509
+ const root = makeRepo(store);
510
+ writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
511
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
512
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
513
+ return root;
514
+ }
515
+
516
+ test("/choro/repos 自动重跑:开关关着时即使过期也一次都不刷(默认行为逐字节不变)", async () => {
517
+ const store = createStore(":memory:");
518
+ const root = driftRepo(store);
519
+ try {
520
+ const { calls, choro } = countingChoro(store);
521
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro });
522
+ const url = "/api/dsh-mneme/choro/repos?repo=1";
523
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
524
+ const res = await invoke(handler(routes, REPOS_PATH), url);
525
+ assert.equal(res.status, 200);
526
+ assert.equal(res.body.repos[0].staleReason, "git", "前提:这个仓库确实是过期的");
527
+ assert.equal(calls.length, 0, "开关缺省 false 时读侧一个字节都不许动用户的工作区");
528
+ assert.equal(res.body.autoRefreshed, false);
529
+ } finally {
530
+ rmTempBestEffort(root);
531
+ }
532
+ });
533
+
534
+ test("/choro/repos 自动重跑:开关开 + ?repo= 点名且 git 过期 → 刷一次,回执按新基线说话", async () => {
535
+ const store = createStore(":memory:");
536
+ const root = driftRepo(store);
537
+ try {
538
+ const { calls, choro } = countingChoro(store);
539
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
540
+ const url = "/api/dsh-mneme/choro/repos?repo=1";
541
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
542
+ const res = await invoke(handler(routes, REPOS_PATH), url);
543
+ assert.equal(res.status, 200);
544
+ assert.equal(calls.length, 1, "开关开 + 指名 + git 过期 = 恰好刷这一个仓库");
545
+ assert.equal(res.body.autoRefreshed, true);
546
+ // 刷完必须改成不 stale:否则面板刚刷完就显示「代码已变」,等于谎报一次失败。
547
+ assert.equal(res.body.repos[0].stale, false);
548
+ assert.equal(res.body.repos[0].staleReason, null);
549
+ } finally {
550
+ rmTempBestEffort(root);
551
+ }
552
+ });
553
+
554
+ test("/choro/repos 自动重跑:不 stale 或没点名 ?repo= 时都不刷(一次面板加载不能扫全库)", async () => {
555
+ const store = createStore(":memory:");
556
+ const cleanRoot = makeRepo(store);
557
+ try {
558
+ const { calls, choro } = countingChoro(store);
559
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
560
+ const plain = "/api/dsh-mneme/choro/repos";
561
+ const clean = await invoke(handler(routes, plain), plain);
562
+ assert.equal(clean.status, 200);
563
+ assert.equal(calls.length, 0, "staleReason 为 null → 没有刷的理由");
564
+
565
+ // 造第二个过期仓库,但请求不带 ?repo=:列表路径一个都不许刷。
566
+ const drifted = driftRepo(store);
567
+ try {
568
+ const listed = await invoke(handler(routes, plain), plain);
569
+ assert.equal(listed.body.repos.length, 2);
570
+ assert.equal(calls.length, 0, "没点名就不刷:否则打开面板要付 N 次全仓扫描");
571
+ assert.equal(listed.body.autoRefreshed, false);
572
+ assert.ok(listed.body.repos.some((r) => r.staleReason === "git"), "过期信号照常回,只是不顺手刷");
573
+ } finally {
574
+ rmTempBestEffort(drifted);
575
+ }
576
+ } finally {
577
+ rmTempBestEffort(cleanRoot);
578
+ }
579
+ });
580
+
581
+ test("/choro/repos 自动重跑失败:读照常 200、数据在、错误进 autoRefreshError", async () => {
582
+ const store = createStore(":memory:");
583
+ const root = driftRepo(store);
584
+ try {
585
+ const { calls, choro } = countingChoro(store, { throwOnRun: true });
586
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
587
+ const url = "/api/dsh-mneme/choro/repos?repo=1";
588
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
589
+ const res = await invoke(handler(routes, REPOS_PATH), url);
590
+ // 读是主诉求、刷新是附赠:打成 500 等于「顺手的事没办成,正事也不给办」。
591
+ assert.equal(res.status, 200, "刷新失败不许把读请求打成 500");
592
+ assert.equal(calls.length, 1);
593
+ assert.equal(res.body.autoRefreshed, false);
594
+ assert.match(res.body.autoRefreshError, /磁盘只读/);
595
+ assert.equal(res.body.repos.length, 1, "数据必须还在");
596
+ assert.equal(res.body.repos[0].staleReason, "git", "没刷成功就照实说过期");
597
+ } finally {
598
+ rmTempBestEffort(root);
599
+ }
600
+ });
601
+
602
+ // 自动刷新碰上已富化的仓库:不许动手。这条是「静默降级」的正对面——读页本来
603
+ // 只是个浏览动作,却会替用户把语义段和 claims 一起清掉,而屏幕上没有任何提示。
604
+ // 现在它变成一次主动拒绝,且拒绝与失败分开报(autoRefreshSkipped vs autoRefreshError)。
605
+ test("/choro/repos 自动重跑:仓库里有富化页时主动跳过,不动字节、不说成失败", async () => {
606
+ const store = createStore(":memory:");
607
+ const root = driftRepo(store);
608
+ try {
609
+ const repo = store.choroRepoByRoot(root);
610
+ const page = store.choroPageBySlug(repo.id, "src");
611
+ store.markChoroPageEnriched(page.id, "2026-11-01T00:00:00.000Z", null);
612
+ const pagePath = join(root, page.rel_path);
613
+ writeFileSync(pagePath, `${readFileSync(pagePath, "utf8")}\n<!-- CHORO:T1:START -->\n## 架构\n\na.js 是入口。\n<!-- CHORO:T1:END -->\n`, "utf8");
614
+ const before = readFileSync(pagePath, "utf8");
615
+
616
+ const { calls, choro } = countingChoro(store);
617
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
618
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
619
+ const res = await invoke(handler(routes, REPOS_PATH), `${REPOS_PATH}?repo=1`);
620
+
621
+ assert.equal(res.status, 200);
622
+ assert.equal(calls.length, 0, "有富化页时连 T0 都不该被叫起来");
623
+ assert.equal(res.body.autoRefreshed, false);
624
+ assert.equal(res.body.autoRefreshSkipped, "t1-loss", "跳过要和失败分开报");
625
+ assert.equal(res.body.autoRefreshError, undefined, "主动拒绝不是错误");
626
+ assert.match(res.body.autoRefreshSkipNote, /1 页/, "说清为什么没做");
627
+ assert.equal(readFileSync(pagePath, "utf8"), before, "页一字节未动");
628
+ assert.equal(res.body.repos[0].staleReason, "git", "没刷就照实说过期");
629
+ assert.equal(res.body.repos[0].enrichedPages, 1, "面板要靠这个数解释原因");
630
+ } finally {
631
+ store.close();
632
+ rmTempBestEffort(root);
633
+ }
634
+ });
635
+
636
+ // 库里的账和磁盘对不上时(status 还是 skeleton、页里却有 T1 段),T0 的读盘判断
637
+ // 会抛 would-drop-t1。到这一层仍按「主动跳过」回,绝不报成刷新失败。
638
+ test("/choro/page 自动重跑:T0 拒绝(would-drop-t1)时回 skipped 而非 error", async () => {
639
+ const store = createStore(":memory:");
640
+ const root = driftRepo(store);
641
+ try {
642
+ const repo = store.choroRepoByRoot(root);
643
+ const page = store.choroPageBySlug(repo.id, "src");
644
+ const pagePath = join(root, page.rel_path);
645
+ // 故意不改库里的 status:模拟「有人手改了 MD」这类账实不符。
646
+ writeFileSync(pagePath, `${readFileSync(pagePath, "utf8")}\n<!-- CHORO:T1:START -->\n## 架构\n\na.js 是入口。\n<!-- CHORO:T1:END -->\n`, "utf8");
647
+ const before = readFileSync(pagePath, "utf8");
648
+
649
+ const { choro } = countingChoro(store);
650
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
651
+ const PAGE_PATH = "/api/dsh-mneme/choro/page";
652
+ const res = await invoke(handler(routes, PAGE_PATH), `${PAGE_PATH}?repo=1&slug=src`);
653
+
654
+ assert.equal(res.status, 200);
655
+ assert.equal(res.body.autoRefreshed, false);
656
+ assert.equal(res.body.autoRefreshSkipped, "t1-loss");
657
+ assert.equal(res.body.autoRefreshError, undefined);
658
+ assert.match(res.body.autoRefreshSkipNote, /富化/, "T0 那句人话原样透出去");
659
+ assert.equal(readFileSync(pagePath, "utf8"), before, "拒绝发生在写盘之前");
660
+ assert.ok(res.body.page.content.includes("CHORO:T1:START"), "页正文照常回,别把读也带坏");
661
+ } finally {
662
+ store.close();
663
+ rmTempBestEffort(root);
664
+ }
665
+ });
666
+
667
+ // /choro/page 是面板主路径,也是「读地志页时发现代码变了」那句文案的真正时刻。
668
+ // 顶栏「代码已变」徽章读 state.repo.stale —— 回执里没有 repo 这份数据,徽章永远不亮。
669
+ test("/choro/page 回执带 repo.stale/staleReason,并在这条读路径上自动重跑", async () => {
670
+ const store = createStore(":memory:");
671
+ const root = driftRepo(store);
672
+ try {
673
+ const { calls, choro } = countingChoro(store);
674
+ const url = "/api/dsh-mneme/choro/page?repo=1&slug=src";
675
+ const PAGE_PATH = "/api/dsh-mneme/choro/page";
676
+
677
+ const offSetup = setup({ config: { chorographyEnabled: true }, choro });
678
+ const off = await invoke(handler(offSetup.routes, PAGE_PATH), url);
679
+ assert.equal(off.status, 200);
680
+ assert.equal(off.body.repo.stale, true, "徽章的数据源:开关关着也要照实报过期");
681
+ assert.equal(off.body.repo.staleReason, "git");
682
+ assert.equal(off.body.autoRefreshed, false);
683
+ assert.equal(calls.length, 0);
684
+
685
+ const onSetup = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
686
+ const on = await invoke(handler(onSetup.routes, PAGE_PATH), url);
687
+ assert.equal(on.status, 200);
688
+ assert.equal(calls.length, 1, "页读路径点名的就是这一个仓库");
689
+ assert.equal(on.body.autoRefreshed, true);
690
+ assert.equal(on.body.repo.stale, false, "刷完页面与 repo 徽章必须同一时刻一致");
691
+ assert.ok(on.body.page.content.includes("## 文件清单"), "页正文照常回");
692
+ } finally {
693
+ rmTempBestEffort(root);
694
+ }
695
+ });
696
+ // --- /choro/claims(claim 粒度失效,Grounded Claims)-----------------------
697
+ // 锁的是一条态度:算不出来 ≠ 没事。基线认不出时必须 anchorStale,不许回一张空清单
698
+ // 让面板显示「全部结论都还准」——那是 #607 同类的假成功。
699
+
700
+ test("/choro/claims 在册且 policy open,四层闸与其余读路由一致", async () => {
701
+ const off = setup({});
702
+ const route = handler(off.routes, "/api/dsh-mneme/choro/claims");
703
+ assert.ok(route, "/choro/claims 在册");
704
+ assert.equal(route.policy, "open");
705
+ const a = await invoke(route, "/api/dsh-mneme/choro/claims");
706
+ assert.equal(a.status, 503, "queries 缺装配 → 503");
707
+
708
+ const disabled = setup({ config: { chorographyEnabled: false }, choro: createChoroQueries({ store: createStore(":memory:") }) });
709
+ const b = await invoke(handler(disabled.routes, "/api/dsh-mneme/choro/claims"), "/api/dsh-mneme/choro/claims");
710
+ assert.equal(b.status, 409, "开关关 → 409");
711
+ assert.equal(b.body.error, "disabled");
712
+ });
713
+
714
+ test("/choro/claims:快乐路径带页清单与 totals;未知 repo 404", async () => {
715
+ const store = createStore(":memory:");
716
+ const root = makeRepo(store);
717
+ try {
718
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
719
+ const route = handler(routes, "/api/dsh-mneme/choro/claims");
720
+ const repoId = store.db.prepare("SELECT id FROM choro_repos LIMIT 1").get().id;
721
+ const r = await invoke(route, `/api/dsh-mneme/choro/claims?repo=${repoId}`);
722
+ assert.equal(r.status, 200);
723
+ assert.ok(r.body.repo && r.body.repo.id === repoId);
724
+ assert.equal(typeof r.body.head, "string");
725
+ assert.ok(Array.isArray(r.body.pages), "页清单要在——面板靠它说「这页没问题」");
726
+ assert.ok(r.body.totals && Number.isInteger(r.body.totals.claims));
727
+ assert.equal(r.body.clean, true, "刚跑完 T0,基线就是 HEAD");
728
+
729
+ const missing = await invoke(route, "/api/dsh-mneme/choro/claims?repo=999999");
730
+ assert.equal(missing.status, 404);
731
+ } finally {
732
+ store.close();
733
+ rmTempBestEffort(root);
734
+ }
735
+ });