@remnic/core 9.3.689 → 9.3.690

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 (158) hide show
  1. package/dist/access-boundary.d.ts +5 -4
  2. package/dist/access-boundary.js +10 -9
  3. package/dist/access-cli.js +86 -22
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +4 -3
  6. package/dist/access-http.js +13 -12
  7. package/dist/access-mcp.d.ts +11 -3
  8. package/dist/access-mcp.js +12 -11
  9. package/dist/access-operations.d.ts +11 -5
  10. package/dist/access-operations.js +13 -10
  11. package/dist/{access-service-DmCHJ4cH.d.ts → access-service-Dujr3MTm.d.ts} +62 -2
  12. package/dist/access-service.d.ts +4 -3
  13. package/dist/access-service.js +9 -8
  14. package/dist/access-surface-catalog.d.ts +4 -3
  15. package/dist/access-surface-catalog.js +2 -0
  16. package/dist/access-surface-catalog.js.map +1 -1
  17. package/dist/bootstrap.d.ts +3 -2
  18. package/dist/briefing.d.ts +1 -0
  19. package/dist/briefing.js +3 -2
  20. package/dist/buffer.d.ts +1 -0
  21. package/dist/{catalog-COqWZlZ6.d.ts → catalog-CKPtJ114.d.ts} +1 -1
  22. package/dist/causal-consolidation.js +4 -3
  23. package/dist/causal-consolidation.js.map +1 -1
  24. package/dist/{chunk-B4XVLHJA.js → chunk-2N6UNJSX.js} +2 -2
  25. package/dist/{chunk-OWFY6NGQ.js → chunk-46URPRE6.js} +2 -2
  26. package/dist/chunk-46URPRE6.js.map +1 -0
  27. package/dist/{chunk-4N3TFFPH.js → chunk-54PVJDO5.js} +2 -2
  28. package/dist/{chunk-PH3HOKYW.js → chunk-67MMWC74.js} +2 -2
  29. package/dist/{chunk-STOEE37X.js → chunk-BKAMHZYR.js} +2 -2
  30. package/dist/{chunk-JZBFL7RI.js → chunk-CE6CBRCV.js} +429 -2237
  31. package/dist/chunk-CE6CBRCV.js.map +1 -0
  32. package/dist/{chunk-UHUZXWDX.js → chunk-CP2NZQLT.js} +15 -4
  33. package/dist/chunk-CP2NZQLT.js.map +1 -0
  34. package/dist/{chunk-BLIWOONZ.js → chunk-CUNTLEJP.js} +4 -12
  35. package/dist/chunk-CUNTLEJP.js.map +1 -0
  36. package/dist/{chunk-W63OY3J7.js → chunk-CWE74HRG.js} +3 -3
  37. package/dist/{chunk-VX6OBUDW.js → chunk-GR77Z2BM.js} +2 -2
  38. package/dist/{chunk-SAEZIIID.js → chunk-LXIEXSHU.js} +2 -2
  39. package/dist/{chunk-Z2M6YTAJ.js → chunk-NSVXK7M5.js} +33 -4
  40. package/dist/chunk-NSVXK7M5.js.map +1 -0
  41. package/dist/{chunk-UXLZOVCN.js → chunk-OMKUJTVJ.js} +37 -5
  42. package/dist/chunk-OMKUJTVJ.js.map +1 -0
  43. package/dist/{chunk-GS55WYRL.js → chunk-PHZKALOE.js} +2 -2
  44. package/dist/{chunk-6O6A6YUO.js → chunk-RGNEARXW.js} +3 -3
  45. package/dist/{chunk-4FE2K57M.js → chunk-RTFAZOIR.js} +2 -2
  46. package/dist/{chunk-UTYBJR7M.js → chunk-SB6CQTKP.js} +2 -2
  47. package/dist/chunk-SVOZFLIQ.js +16 -0
  48. package/dist/chunk-SVOZFLIQ.js.map +1 -0
  49. package/dist/{chunk-JPETDXED.js → chunk-TYF3D4MS.js} +2 -2
  50. package/dist/{chunk-WIHPNY65.js → chunk-UD5OKH4J.js} +2 -2
  51. package/dist/{chunk-QANVLERJ.js → chunk-UPIBE2DK.js} +2 -2
  52. package/dist/{chunk-RHXFYIHA.js → chunk-WROKD3XC.js} +33 -18
  53. package/dist/chunk-WROKD3XC.js.map +1 -0
  54. package/dist/{chunk-2SNKUSQC.js → chunk-X5J3JZR3.js} +4 -4
  55. package/dist/{chunk-OV4D5T7V.js → chunk-X7RLU5CR.js} +2 -2
  56. package/dist/{chunk-QSQW54U5.js → chunk-XKUKJIOY.js} +30 -7
  57. package/dist/chunk-XKUKJIOY.js.map +1 -0
  58. package/dist/{chunk-XU7363OX.js → chunk-Z7KILAOU.js} +603 -9
  59. package/dist/chunk-Z7KILAOU.js.map +1 -0
  60. package/dist/chunk-ZU7N3S6V.js +2190 -0
  61. package/dist/chunk-ZU7N3S6V.js.map +1 -0
  62. package/dist/{chunk-T422SYM6.js → chunk-ZUDM75KG.js} +5 -5
  63. package/dist/{cli-D8nZ2MPH.d.ts → cli-BkDp6WNi.d.ts} +2 -2
  64. package/dist/cli.d.ts +5 -4
  65. package/dist/cli.js +25 -23
  66. package/dist/compounding/engine.d.ts +1 -0
  67. package/dist/compounding/engine.js +3 -2
  68. package/dist/connectors/codex-materialize-runner.js +3 -2
  69. package/dist/connectors/index.js +3 -2
  70. package/dist/consolidation-provenance-check.d.ts +1 -0
  71. package/dist/consolidation-undo.d.ts +1 -0
  72. package/dist/contradiction/index.d.ts +1 -0
  73. package/dist/conversation-index/backend.js +2 -2
  74. package/dist/entity-retrieval.d.ts +1 -0
  75. package/dist/entity-retrieval.js +3 -2
  76. package/dist/explicit-capture.d.ts +3 -2
  77. package/dist/index.d.ts +6 -5
  78. package/dist/index.js +34 -31
  79. package/dist/index.js.map +1 -1
  80. package/dist/lcm/engine.js +2 -2
  81. package/dist/lcm/index.js +2 -2
  82. package/dist/maintenance/memory-governance.js +3 -2
  83. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -2
  84. package/dist/maintenance/rebuild-memory-projection.js +4 -3
  85. package/dist/mcp-memory-inspector-app.d.ts +4 -3
  86. package/dist/memory-worth-outcomes.d.ts +1 -0
  87. package/dist/namespaces/migrate.d.ts +2 -1
  88. package/dist/namespaces/migrate.js +8 -7
  89. package/dist/namespaces/search.js +4 -4
  90. package/dist/namespaces/storage.d.ts +2 -1
  91. package/dist/namespaces/storage.js +3 -2
  92. package/dist/operator-toolkit.d.ts +1 -0
  93. package/dist/operator-toolkit.js +11 -9
  94. package/dist/{orchestrator-CA6ouzBn.d.ts → orchestrator-B7ixmUkP.d.ts} +145 -1
  95. package/dist/orchestrator.d.ts +3 -2
  96. package/dist/orchestrator.js +18 -16
  97. package/dist/schemas.d.ts +10 -10
  98. package/dist/search/factory.js +3 -3
  99. package/dist/search/index.js +3 -3
  100. package/dist/semantic-consolidation.js +4 -3
  101. package/dist/semantic-rule-promotion.js +3 -2
  102. package/dist/semantic-rule-verifier.js +3 -2
  103. package/dist/storage.d.ts +3 -27
  104. package/dist/storage.js +5 -3
  105. package/dist/structured-attributes.d.ts +29 -0
  106. package/dist/structured-attributes.js +8 -0
  107. package/dist/structured-attributes.js.map +1 -0
  108. package/dist/temporal-supersession.d.ts +1 -0
  109. package/dist/tier-migration.d.ts +1 -0
  110. package/dist/verified-recall.js +3 -2
  111. package/package.json +2 -2
  112. package/src/access-boundary.ts +2 -1
  113. package/src/access-cli.test.ts +40 -0
  114. package/src/access-cli.ts +90 -2
  115. package/src/access-http.ts +35 -6
  116. package/src/access-mcp.ts +34 -0
  117. package/src/access-operations.ts +45 -0
  118. package/src/access-service.ts +60 -0
  119. package/src/access-surface-catalog.test.ts +1 -1
  120. package/src/access-surface-catalog.ts +2 -0
  121. package/src/cli.ts +18 -0
  122. package/src/coding/architecture-card.test.ts +544 -0
  123. package/src/coding/architecture-card.ts +687 -0
  124. package/src/coding/architecture-surfaces.test.ts +579 -0
  125. package/src/coding/architecture-surfaces.ts +457 -0
  126. package/src/maintenance/namespace-maintenance-fanout.test.ts +595 -0
  127. package/src/maintenance/namespace-maintenance-fanout.ts +318 -0
  128. package/src/maintenance/namespace-planner.ts +74 -16
  129. package/src/operator-toolkit.ts +25 -0
  130. package/src/orchestrator.ts +144 -0
  131. package/src/storage.ts +6 -20
  132. package/src/structured-attributes.ts +39 -0
  133. package/dist/chunk-BLIWOONZ.js.map +0 -1
  134. package/dist/chunk-JZBFL7RI.js.map +0 -1
  135. package/dist/chunk-OWFY6NGQ.js.map +0 -1
  136. package/dist/chunk-QSQW54U5.js.map +0 -1
  137. package/dist/chunk-RHXFYIHA.js.map +0 -1
  138. package/dist/chunk-UHUZXWDX.js.map +0 -1
  139. package/dist/chunk-UXLZOVCN.js.map +0 -1
  140. package/dist/chunk-XU7363OX.js.map +0 -1
  141. package/dist/chunk-Z2M6YTAJ.js.map +0 -1
  142. /package/dist/{chunk-B4XVLHJA.js.map → chunk-2N6UNJSX.js.map} +0 -0
  143. /package/dist/{chunk-4N3TFFPH.js.map → chunk-54PVJDO5.js.map} +0 -0
  144. /package/dist/{chunk-PH3HOKYW.js.map → chunk-67MMWC74.js.map} +0 -0
  145. /package/dist/{chunk-STOEE37X.js.map → chunk-BKAMHZYR.js.map} +0 -0
  146. /package/dist/{chunk-W63OY3J7.js.map → chunk-CWE74HRG.js.map} +0 -0
  147. /package/dist/{chunk-VX6OBUDW.js.map → chunk-GR77Z2BM.js.map} +0 -0
  148. /package/dist/{chunk-SAEZIIID.js.map → chunk-LXIEXSHU.js.map} +0 -0
  149. /package/dist/{chunk-GS55WYRL.js.map → chunk-PHZKALOE.js.map} +0 -0
  150. /package/dist/{chunk-6O6A6YUO.js.map → chunk-RGNEARXW.js.map} +0 -0
  151. /package/dist/{chunk-4FE2K57M.js.map → chunk-RTFAZOIR.js.map} +0 -0
  152. /package/dist/{chunk-UTYBJR7M.js.map → chunk-SB6CQTKP.js.map} +0 -0
  153. /package/dist/{chunk-JPETDXED.js.map → chunk-TYF3D4MS.js.map} +0 -0
  154. /package/dist/{chunk-WIHPNY65.js.map → chunk-UD5OKH4J.js.map} +0 -0
  155. /package/dist/{chunk-QANVLERJ.js.map → chunk-UPIBE2DK.js.map} +0 -0
  156. /package/dist/{chunk-2SNKUSQC.js.map → chunk-X5J3JZR3.js.map} +0 -0
  157. /package/dist/{chunk-OV4D5T7V.js.map → chunk-X7RLU5CR.js.map} +0 -0
  158. /package/dist/{chunk-T422SYM6.js.map → chunk-ZUDM75KG.js.map} +0 -0
@@ -0,0 +1,595 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import type { NamespaceCatalog, NamespaceRecord } from "../namespaces/catalog.js";
8
+ import { namespaceIdentityToken } from "../namespaces/identity.js";
9
+ import type { PluginConfig } from "../types.js";
10
+ import {
11
+ NAMESPACE_MAINTENANCE_JOBS,
12
+ formatNamespaceMaintenanceHealthText,
13
+ runNamespaceMaintenanceFanout,
14
+ summarizeNamespaceMaintenanceHealth,
15
+ } from "./namespace-maintenance-fanout.js";
16
+
17
+ function makeConfig(memoryDir: string, overrides: Partial<PluginConfig> = {}): PluginConfig {
18
+ return {
19
+ memoryDir,
20
+ namespacesEnabled: true,
21
+ namespaceCatalogEnabled: true,
22
+ defaultNamespace: "default",
23
+ sharedNamespace: "shared",
24
+ namespacePolicies: [],
25
+ maintenanceNamespaceFanoutEnabled: true,
26
+ maintenanceMaxNamespacesPerCycle: 20,
27
+ maintenanceIncludeProjectNamespaces: true,
28
+ maintenanceIncludeBranchNamespaces: false,
29
+ maintenanceIncludeTeamProjectNamespaces: true,
30
+ maintenanceNamespaceLockStaleMs: 10 * 60_000,
31
+ qmdCollection: "remnic",
32
+ entitySchemas: {},
33
+ inlineSourceAttributionFormat: undefined,
34
+ ...overrides,
35
+ } as unknown as PluginConfig;
36
+ }
37
+
38
+ async function mkMemoryDir(): Promise<string> {
39
+ return mkdtemp(path.join(os.tmpdir(), "remnic-fanout-"));
40
+ }
41
+
42
+ async function createNamespaceData(memoryDir: string, namespace: string): Promise<string> {
43
+ const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(namespace));
44
+ await mkdir(path.join(storageDir, "facts"), { recursive: true });
45
+ await writeFile(path.join(storageDir, "facts", "sample.md"), "# sample\n", "utf8");
46
+ return storageDir;
47
+ }
48
+
49
+ function fakeCatalog(records: NamespaceRecord[]): NamespaceCatalog {
50
+ return {
51
+ enabled: true,
52
+ async listNamespaces() {
53
+ return records;
54
+ },
55
+ async markMaintenance() {},
56
+ } as unknown as NamespaceCatalog;
57
+ }
58
+
59
+ function record(
60
+ memoryDir: string,
61
+ namespace: string,
62
+ kind: NamespaceRecord["kind"],
63
+ lastWriteAt: string,
64
+ ): NamespaceRecord {
65
+ return {
66
+ namespace,
67
+ identityToken: namespaceIdentityToken(namespace),
68
+ kind,
69
+ createdAt: "2026-06-30T00:00:00.000Z",
70
+ lastWriteAt,
71
+ storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken(namespace)),
72
+ discoveredBy: "write",
73
+ };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // runNamespaceMaintenanceFanout
78
+ // ---------------------------------------------------------------------------
79
+
80
+ test("fanout runs a job across all maintained namespaces (default + catalog)", async () => {
81
+ const memoryDir = await mkMemoryDir();
82
+ try {
83
+ await createNamespaceData(memoryDir, "project-alpha");
84
+ await createNamespaceData(memoryDir, "team-pi-project-beta");
85
+
86
+ const config = makeConfig(memoryDir);
87
+ const catalog = fakeCatalog([
88
+ record(memoryDir, "project-alpha", "project", "2026-07-01T10:00:00.000Z"),
89
+ record(memoryDir, "team-pi-project-beta", "team-project", "2026-07-01T11:00:00.000Z"),
90
+ ]);
91
+
92
+ const seen: string[] = [];
93
+ const summary = await runNamespaceMaintenanceFanout({
94
+ config,
95
+ catalog,
96
+ jobName: "pattern-reinforcement",
97
+ resolveStorage: async () => ({}),
98
+ runner: async (ctx) => {
99
+ seen.push(ctx.candidate.namespace);
100
+ return { itemCount: 1 };
101
+ },
102
+ });
103
+
104
+ // default + shared (configured) + project-alpha + team-pi-project-beta
105
+ assert.ok(seen.includes("default"), "default namespace should be maintained");
106
+ assert.ok(seen.includes("project-alpha"), "project namespace should be maintained");
107
+ assert.ok(seen.includes("team-pi-project-beta"), "team-project namespace should be maintained");
108
+ assert.equal(summary.ran, seen.length);
109
+ assert.equal(summary.failed, 0);
110
+ } finally {
111
+ await rm(memoryDir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("fanout skips branch namespaces by default", async () => {
116
+ const memoryDir = await mkMemoryDir();
117
+ try {
118
+ await createNamespaceData(memoryDir, "project-alpha-branch-main");
119
+
120
+ const config = makeConfig(memoryDir);
121
+ const catalog = fakeCatalog([
122
+ record(memoryDir, "project-alpha-branch-main", "branch", "2026-07-01T12:00:00.000Z"),
123
+ ]);
124
+
125
+ const seen: string[] = [];
126
+ const summary = await runNamespaceMaintenanceFanout({
127
+ config,
128
+ catalog,
129
+ jobName: "graph-decay",
130
+ resolveStorage: async () => ({}),
131
+ runner: async (ctx) => {
132
+ seen.push(ctx.candidate.namespace);
133
+ return undefined;
134
+ },
135
+ });
136
+
137
+ assert.ok(
138
+ !seen.includes("project-alpha-branch-main"),
139
+ "branch namespace should be skipped by default",
140
+ );
141
+ assert.ok(
142
+ summary.skipped >= 1,
143
+ "branch namespace should appear in skipped count",
144
+ );
145
+ } finally {
146
+ await rm(memoryDir, { recursive: true, force: true });
147
+ }
148
+ });
149
+
150
+ test("fanout includes branch namespaces when config enables them", async () => {
151
+ const memoryDir = await mkMemoryDir();
152
+ try {
153
+ await createNamespaceData(memoryDir, "project-alpha-branch-main");
154
+
155
+ const config = makeConfig(memoryDir, {
156
+ maintenanceIncludeBranchNamespaces: true,
157
+ });
158
+ const catalog = fakeCatalog([
159
+ record(memoryDir, "project-alpha-branch-main", "branch", "2026-07-01T12:00:00.000Z"),
160
+ ]);
161
+
162
+ const seen: string[] = [];
163
+ await runNamespaceMaintenanceFanout({
164
+ config,
165
+ catalog,
166
+ jobName: "graph-decay",
167
+ resolveStorage: async () => ({}),
168
+ runner: async (ctx) => {
169
+ seen.push(ctx.candidate.namespace);
170
+ return undefined;
171
+ },
172
+ });
173
+
174
+ assert.ok(
175
+ seen.includes("project-alpha-branch-main"),
176
+ "branch namespace should be maintained when config enables it",
177
+ );
178
+ } finally {
179
+ await rm(memoryDir, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test("fanout isolates per-namespace failures (one namespace failing does not abort others)", async () => {
184
+ const memoryDir = await mkMemoryDir();
185
+ try {
186
+ await createNamespaceData(memoryDir, "project-good");
187
+ await createNamespaceData(memoryDir, "project-bad");
188
+
189
+ const config = makeConfig(memoryDir);
190
+ const catalog = fakeCatalog([
191
+ record(memoryDir, "project-good", "project", "2026-07-01T10:00:00.000Z"),
192
+ record(memoryDir, "project-bad", "project", "2026-07-01T11:00:00.000Z"),
193
+ ]);
194
+
195
+ const summary = await runNamespaceMaintenanceFanout({
196
+ config,
197
+ catalog,
198
+ jobName: "semantic-consolidation",
199
+ resolveStorage: async () => ({}),
200
+ runner: async (ctx) => {
201
+ if (ctx.candidate.namespace === "project-bad") {
202
+ throw new Error("simulated namespace failure");
203
+ }
204
+ return { itemCount: 5 };
205
+ },
206
+ });
207
+
208
+ assert.ok(summary.failed >= 1, "failing namespace should be recorded as failed");
209
+ assert.ok(summary.ran >= 1, "other namespaces should still run");
210
+ const failedStatus = summary.statuses.find((s) => s.state === "failed");
211
+ assert.ok(failedStatus, "failed status should be recorded");
212
+ assert.ok(failedStatus!.error, "failed status should record an error detail");
213
+ } finally {
214
+ await rm(memoryDir, { recursive: true, force: true });
215
+ }
216
+ });
217
+
218
+ test("fanout returns zero-summary when enabled=false (job gate off)", async () => {
219
+ const memoryDir = await mkMemoryDir();
220
+ try {
221
+ const config = makeConfig(memoryDir);
222
+ const catalog = fakeCatalog([
223
+ record(memoryDir, "project-alpha", "project", "2026-07-01T10:00:00.000Z"),
224
+ ]);
225
+
226
+ let called = false;
227
+ const summary = await runNamespaceMaintenanceFanout({
228
+ config,
229
+ catalog,
230
+ jobName: "pattern-reinforcement",
231
+ resolveStorage: async () => ({}),
232
+ enabled: false,
233
+ runner: async () => {
234
+ called = true;
235
+ return undefined;
236
+ },
237
+ });
238
+
239
+ assert.equal(called, false, "runner must not be called when enabled=false");
240
+ assert.equal(summary.ran, 0);
241
+ assert.equal(summary.skipped, 0);
242
+ assert.equal(summary.failed, 0);
243
+ } finally {
244
+ await rm(memoryDir, { recursive: true, force: true });
245
+ }
246
+ });
247
+
248
+ test("fanout passes resolveStorage to the runner for per-namespace storage resolution", async () => {
249
+ const memoryDir = await mkMemoryDir();
250
+ try {
251
+ const config = makeConfig(memoryDir);
252
+ const resolvedNamespaces: string[] = [];
253
+
254
+ await runNamespaceMaintenanceFanout({
255
+ config,
256
+ jobName: "lifecycle",
257
+ resolveStorage: async (namespace) => {
258
+ resolvedNamespaces.push(namespace);
259
+ return { dir: path.join(memoryDir, "namespaces", namespaceIdentityToken(namespace)) };
260
+ },
261
+ runner: async (ctx) => {
262
+ const storage = await ctx.resolveStorage(ctx.candidate.namespace);
263
+ assert.ok(typeof (storage as { dir: string }).dir === "string", "storage should be resolved");
264
+ return { itemCount: 1 };
265
+ },
266
+ });
267
+
268
+ assert.ok(
269
+ resolvedNamespaces.includes("default"),
270
+ "default namespace storage should be resolved",
271
+ );
272
+ } finally {
273
+ await rm(memoryDir, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ test("fanout respects maxNamespacesPerCycle budget", async () => {
278
+ const memoryDir = await mkMemoryDir();
279
+ try {
280
+ await createNamespaceData(memoryDir, "project-a");
281
+ await createNamespaceData(memoryDir, "project-b");
282
+ await createNamespaceData(memoryDir, "project-c");
283
+
284
+ const config = makeConfig(memoryDir, { maintenanceMaxNamespacesPerCycle: 2 });
285
+ const catalog = fakeCatalog([
286
+ record(memoryDir, "project-a", "project", "2026-07-01T10:00:00.000Z"),
287
+ record(memoryDir, "project-b", "project", "2026-07-01T11:00:00.000Z"),
288
+ record(memoryDir, "project-c", "project", "2026-07-01T12:00:00.000Z"),
289
+ ]);
290
+
291
+ const seen: string[] = [];
292
+ const summary = await runNamespaceMaintenanceFanout({
293
+ config,
294
+ catalog,
295
+ jobName: "pattern-reinforcement",
296
+ resolveStorage: async () => ({}),
297
+ runner: async (ctx) => {
298
+ seen.push(ctx.candidate.namespace);
299
+ return undefined;
300
+ },
301
+ });
302
+
303
+ // Budget caps the selected count. Default + shared (configured) are always
304
+ // included, so the total selected is min(maxNamespacesPerCycle, candidates).
305
+ assert.ok(
306
+ summary.ran + summary.skipped <= 2 || seen.length <= 2,
307
+ "budget should cap the number of namespaces processed",
308
+ );
309
+ } finally {
310
+ await rm(memoryDir, { recursive: true, force: true });
311
+ }
312
+ });
313
+
314
+ test("fanout updates lastMaintenanceAt through the catalog", async () => {
315
+ const memoryDir = await mkMemoryDir();
316
+ try {
317
+ await createNamespaceData(memoryDir, "project-alpha");
318
+
319
+ const config = makeConfig(memoryDir);
320
+ const marked: Array<{ namespace: string; jobName: string }> = [];
321
+ const catalog = {
322
+ enabled: true,
323
+ async listNamespaces() {
324
+ return [record(memoryDir, "project-alpha", "project", "2026-07-01T10:00:00.000Z")];
325
+ },
326
+ async markMaintenance(namespace: string, jobName: string) {
327
+ marked.push({ namespace, jobName });
328
+ },
329
+ } as unknown as NamespaceCatalog;
330
+
331
+ await runNamespaceMaintenanceFanout({
332
+ config,
333
+ catalog,
334
+ jobName: "pattern-reinforcement",
335
+ resolveStorage: async () => ({}),
336
+ runner: async () => ({ itemCount: 1 }),
337
+ });
338
+
339
+ const alphaMark = marked.find((m) => m.namespace === "project-alpha");
340
+ assert.ok(alphaMark, "catalog markMaintenance should be called for project-alpha");
341
+ assert.equal(alphaMark!.jobName, "pattern-reinforcement");
342
+ } finally {
343
+ await rm(memoryDir, { recursive: true, force: true });
344
+ }
345
+ });
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Namespaces-disabled behavior (single-user compatibility)
349
+ // ---------------------------------------------------------------------------
350
+
351
+ test("fanout runs once against default when namespaces are disabled", async () => {
352
+ const memoryDir = await mkMemoryDir();
353
+ try {
354
+ const config = makeConfig(memoryDir, {
355
+ namespacesEnabled: false,
356
+ namespaceCatalogEnabled: false,
357
+ });
358
+
359
+ const seen: string[] = [];
360
+ const summary = await runNamespaceMaintenanceFanout({
361
+ config,
362
+ jobName: "pattern-reinforcement",
363
+ resolveStorage: async () => ({}),
364
+ runner: async (ctx) => {
365
+ seen.push(ctx.candidate.namespace);
366
+ return undefined;
367
+ },
368
+ });
369
+
370
+ // Namespaces disabled: storageFor() collapses every name to memoryDir, so
371
+ // the planner seeds ONLY the default namespace to avoid double-processing
372
+ // the same corpus (review #1622: honor the namespace fanout opt-out).
373
+ assert.deepEqual(seen, ["default"], "only the default namespace is processed when namespaces are disabled");
374
+ assert.equal(summary.ran, 1);
375
+ } finally {
376
+ await rm(memoryDir, { recursive: true, force: true });
377
+ }
378
+ });
379
+
380
+ test("fanout runs once against default when maintenanceNamespaceFanoutEnabled is false", async () => {
381
+ const memoryDir = await mkMemoryDir();
382
+ try {
383
+ await createNamespaceData(memoryDir, "project-alpha");
384
+ const config = makeConfig(memoryDir, {
385
+ maintenanceNamespaceFanoutEnabled: false,
386
+ });
387
+ const catalog = fakeCatalog([
388
+ record(memoryDir, "project-alpha", "project", "2026-07-01T10:00:00.000Z"),
389
+ ]);
390
+
391
+ const seen: string[] = [];
392
+ await runNamespaceMaintenanceFanout({
393
+ config,
394
+ catalog,
395
+ jobName: "pattern-reinforcement",
396
+ resolveStorage: async () => ({}),
397
+ runner: async (ctx) => {
398
+ seen.push(ctx.candidate.namespace);
399
+ return undefined;
400
+ },
401
+ });
402
+
403
+ assert.deepEqual(seen, ["default", "shared"], "configured namespaces are processed even with catalog fanout disabled");
404
+ } finally {
405
+ await rm(memoryDir, { recursive: true, force: true });
406
+ }
407
+ });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // summarizeNamespaceMaintenanceHealth + formatNamespaceMaintenanceHealthText
411
+ // ---------------------------------------------------------------------------
412
+
413
+ test("health summary aggregates per-job statuses and reports fanout config", async () => {
414
+ const memoryDir = await mkMemoryDir();
415
+ try {
416
+ const config = makeConfig(memoryDir);
417
+
418
+ // Run a job to generate status files.
419
+ await runNamespaceMaintenanceFanout({
420
+ config,
421
+ jobName: "pattern-reinforcement",
422
+ resolveStorage: async () => ({}),
423
+ runner: async () => ({ itemCount: 3 }),
424
+ });
425
+
426
+ const summary = await summarizeNamespaceMaintenanceHealth(config);
427
+
428
+ assert.equal(summary.fanoutEnabled, true);
429
+ assert.equal(summary.namespacesEnabled, true);
430
+ assert.equal(summary.maxNamespacesPerCycle, 20);
431
+ assert.ok(summary.totalRan >= 1, "at least one run should be recorded");
432
+
433
+ const prJob = summary.jobs.find((j) => j.jobName === "pattern-reinforcement");
434
+ assert.ok(prJob, "pattern-reinforcement job should appear in health summary");
435
+ assert.ok(prJob!.ran >= 1, "pattern-reinforcement should have at least one ran namespace");
436
+ assert.ok(prJob!.lastRunAt !== null, "lastRunAt should be populated");
437
+ } finally {
438
+ await rm(memoryDir, { recursive: true, force: true });
439
+ }
440
+ });
441
+
442
+ test("health summary includes all standard job names even with no status files", async () => {
443
+ const memoryDir = await mkMemoryDir();
444
+ try {
445
+ const config = makeConfig(memoryDir);
446
+ const summary = await summarizeNamespaceMaintenanceHealth(config);
447
+
448
+ for (const jobName of NAMESPACE_MAINTENANCE_JOBS) {
449
+ const job = summary.jobs.find((j) => j.jobName === jobName);
450
+ assert.ok(job, `standard job '${jobName}' should appear in health summary even with no runs`);
451
+ }
452
+ assert.equal(summary.totalRan, 0);
453
+ assert.equal(summary.totalFailed, 0);
454
+ } finally {
455
+ await rm(memoryDir, { recursive: true, force: true });
456
+ }
457
+ });
458
+
459
+ test("formatNamespaceMaintenanceHealthText produces readable output", async () => {
460
+ const memoryDir = await mkMemoryDir();
461
+ try {
462
+ const config = makeConfig(memoryDir);
463
+
464
+ await runNamespaceMaintenanceFanout({
465
+ config,
466
+ jobName: "graph-decay",
467
+ resolveStorage: async () => ({}),
468
+ runner: async () => ({ itemCount: 10 }),
469
+ });
470
+
471
+ const summary = await summarizeNamespaceMaintenanceHealth(config);
472
+ const text = formatNamespaceMaintenanceHealthText(summary);
473
+
474
+ assert.ok(text.includes("=== Namespace Maintenance ==="), "header should be present");
475
+ assert.ok(text.includes("fanout:"), "fanout status should be present");
476
+ assert.ok(text.includes("graph-decay:"), "job breakdown should include graph-decay");
477
+ } finally {
478
+ await rm(memoryDir, { recursive: true, force: true });
479
+ }
480
+ });
481
+
482
+ test("formatNamespaceMaintenanceHealthText handles empty state", async () => {
483
+ const memoryDir = await mkMemoryDir();
484
+ try {
485
+ const config = makeConfig(memoryDir);
486
+ const summary = await summarizeNamespaceMaintenanceHealth(config);
487
+ const text = formatNamespaceMaintenanceHealthText(summary);
488
+
489
+ assert.ok(text.includes("(no maintenance status recorded yet)"), "should report empty state");
490
+ } finally {
491
+ await rm(memoryDir, { recursive: true, force: true });
492
+ }
493
+ });
494
+
495
+ test("fanout records runner-reported skip as skipped (cadence-skip accuracy, review #1622)", async () => {
496
+ // Regression: when a job's own cadence gate throttles a namespace, the
497
+ // runner resolves without throwing. Before the fix the planner recorded
498
+ // state:"ran" and bumped lastMaintenanceAt, so a throttled namespace
499
+ // looked maintained. Now the runner signals { skipped: true } and the
500
+ // planner records state:"skipped" without touching lastMaintenanceAt.
501
+ const memoryDir = await mkMemoryDir();
502
+ try {
503
+ // Default-only config (namespaces disabled): the planner runs the job
504
+ // exactly once against the default namespace, isolating the skip-signal
505
+ // contract from catalog namespace discovery.
506
+ const config = makeConfig(memoryDir);
507
+
508
+ const summary = await runNamespaceMaintenanceFanout({
509
+ config,
510
+ jobName: "pattern-reinforcement",
511
+ resolveStorage: async () => ({}),
512
+ runner: async () => ({ skipped: true, skipReason: "cadence" }),
513
+ });
514
+
515
+ // Configured namespaces (default + shared) are both processed; the
516
+ // contract under test is that NONE are recorded as ran and EVERY
517
+ // status carries the runner's skip reason — no phantom lastMaintenanceAt.
518
+ assert.equal(summary.ran, 0, "no namespace should be recorded as ran");
519
+ assert.equal(summary.failed, 0);
520
+ assert.equal(summary.skipped, summary.statuses.length, "every processed namespace recorded as skipped");
521
+ assert.ok(summary.statuses.length >= 1, "at least one namespace was processed");
522
+ for (const status of summary.statuses) {
523
+ assert.equal(status.state, "skipped", `namespace ${status.namespace} state`);
524
+ assert.equal(status.reason, "cadence", `namespace ${status.namespace} skipReason propagated`);
525
+ assert.equal(status.itemCount, undefined, `namespace ${status.namespace} no itemCount on skip`);
526
+ }
527
+
528
+ // Health summary reflects the skips, not phantom runs.
529
+ const health = await summarizeNamespaceMaintenanceHealth(config);
530
+ assert.equal(health.totalRan, 0);
531
+ assert.equal(health.totalSkipped, summary.statuses.length);
532
+ } finally {
533
+ await rm(memoryDir, { recursive: true, force: true });
534
+ }
535
+ });
536
+
537
+ test("formatNamespaceMaintenanceHealthText surfaces failed namespace names and reasons (review #1622)", async () => {
538
+ const memoryDir = await mkMemoryDir();
539
+ try {
540
+ const config = makeConfig(memoryDir);
541
+ // Run a job that fails for one namespace and succeeds for another.
542
+ const catalog = fakeCatalog([
543
+ record(memoryDir, "default", "default", new Date().toISOString()),
544
+ ]);
545
+ await runNamespaceMaintenanceFanout({
546
+ config,
547
+ catalog,
548
+ jobName: "governance",
549
+ resolveStorage: async () => { throw new Error("disk full"); },
550
+ runner: async () => { throw new Error("disk full"); },
551
+ });
552
+
553
+ const summary = await summarizeNamespaceMaintenanceHealth(config);
554
+ const text = formatNamespaceMaintenanceHealthText(summary);
555
+ // The failed namespace name and its error must appear in human-readable
556
+ // output so operators know WHAT to fix without --json.
557
+ assert.ok(text.includes("default"), "failed namespace name in output");
558
+ assert.ok(text.includes("disk full") || text.includes("failed"), "failure detail in output");
559
+ } finally {
560
+ await rm(memoryDir, { recursive: true, force: true });
561
+ }
562
+ });
563
+
564
+ test("health summary lastRunAt survives a later skip via last-ran records (review #1622)", async () => {
565
+ const memoryDir = await mkMemoryDir();
566
+ try {
567
+ const config = makeConfig(memoryDir);
568
+
569
+ // First: a successful run writes both <ns>.json (ran) and <ns>.last-ran.json.
570
+ await runNamespaceMaintenanceFanout({
571
+ config,
572
+ jobName: "semantic-consolidation",
573
+ resolveStorage: async () => ({}),
574
+ runner: async () => ({ itemCount: 3 }),
575
+ });
576
+
577
+ // Second: a skip overwrites <ns>.json with "skipped" but leaves last-ran.
578
+ await runNamespaceMaintenanceFanout({
579
+ config,
580
+ jobName: "semantic-consolidation",
581
+ resolveStorage: async () => ({}),
582
+ runner: async () => ({ skipped: true, skipReason: "cadence" }),
583
+ });
584
+
585
+ const health = await summarizeNamespaceMaintenanceHealth(config);
586
+ const job = health.jobs.find((j) => j.jobName === "semantic-consolidation");
587
+ assert.ok(job, "semantic-consolidation job in health summary");
588
+ // The latest outcome is skipped, but lastRunAt must still reflect the
589
+ // prior successful run (from the last-ran record), not null.
590
+ assert.ok(job!.lastRunAt, "lastRunAt preserved from last-ran after a skip");
591
+ assert.notEqual(job!.lastRunAt, null);
592
+ } finally {
593
+ await rm(memoryDir, { recursive: true, force: true });
594
+ }
595
+ });