@remnic/core 9.3.688 → 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 (163) hide show
  1. package/dist/access-boundary.d.ts +5 -4
  2. package/dist/access-boundary.js +12 -11
  3. package/dist/access-cli.js +90 -26
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +4 -3
  6. package/dist/access-http.js +15 -14
  7. package/dist/access-mcp.d.ts +11 -3
  8. package/dist/access-mcp.js +14 -13
  9. package/dist/access-operations.d.ts +11 -5
  10. package/dist/access-operations.js +15 -12
  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 +11 -10
  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-NN7QYW5W.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-B43NZNMG.js → chunk-CE6CBRCV.js} +432 -2240
  31. package/dist/chunk-CE6CBRCV.js.map +1 -0
  32. package/dist/{chunk-RVYD6LR3.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-2SJCWLQD.js → chunk-CWE74HRG.js} +3 -3
  37. package/dist/{chunk-VX6OBUDW.js → chunk-GR77Z2BM.js} +2 -2
  38. package/dist/{chunk-CTOQEZSN.js → chunk-LXIEXSHU.js} +2 -2
  39. package/dist/{chunk-KFBOZYME.js → chunk-NSVXK7M5.js} +33 -4
  40. package/dist/chunk-NSVXK7M5.js.map +1 -0
  41. package/dist/{chunk-473JIN2U.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-2KAYTPPT.js → chunk-TYF3D4MS.js} +5 -5
  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-CCWHPGT4.js → chunk-WROKD3XC.js} +45 -30
  53. package/dist/chunk-WROKD3XC.js.map +1 -0
  54. package/dist/{chunk-ZPTISBQU.js → chunk-X5J3JZR3.js} +7 -7
  55. package/dist/{chunk-OV4D5T7V.js → chunk-X7RLU5CR.js} +2 -2
  56. package/dist/{chunk-S2OU5DZY.js → chunk-XKUKJIOY.js} +30 -7
  57. package/dist/chunk-XKUKJIOY.js.map +1 -0
  58. package/dist/{chunk-FUCUR2OZ.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-DCWIQFNA.js → chunk-ZUDM75KG.js} +4 -4
  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 +32 -30
  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 +47 -44
  79. package/dist/index.js.map +1 -1
  80. package/dist/lcm/engine.js +3 -3
  81. package/dist/lcm/index.js +5 -5
  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 +10 -9
  89. package/dist/namespaces/search.js +6 -6
  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 +13 -11
  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 +22 -20
  97. package/dist/recall-pipeline-stages.d.ts +167 -0
  98. package/dist/recall-pipeline-stages.js +61 -0
  99. package/dist/recall-pipeline-stages.js.map +1 -0
  100. package/dist/schemas.d.ts +10 -10
  101. package/dist/search/factory.js +5 -5
  102. package/dist/search/index.js +9 -9
  103. package/dist/semantic-consolidation.js +4 -3
  104. package/dist/semantic-rule-promotion.js +3 -2
  105. package/dist/semantic-rule-verifier.js +3 -2
  106. package/dist/storage.d.ts +3 -27
  107. package/dist/storage.js +5 -3
  108. package/dist/structured-attributes.d.ts +29 -0
  109. package/dist/structured-attributes.js +8 -0
  110. package/dist/structured-attributes.js.map +1 -0
  111. package/dist/temporal-supersession.d.ts +1 -0
  112. package/dist/tier-migration.d.ts +1 -0
  113. package/dist/verified-recall.js +3 -2
  114. package/package.json +2 -2
  115. package/src/access-boundary.ts +2 -1
  116. package/src/access-cli.test.ts +40 -0
  117. package/src/access-cli.ts +90 -2
  118. package/src/access-http.ts +35 -6
  119. package/src/access-mcp.ts +34 -0
  120. package/src/access-operations.ts +45 -0
  121. package/src/access-service.ts +60 -0
  122. package/src/access-surface-catalog.test.ts +1 -1
  123. package/src/access-surface-catalog.ts +2 -0
  124. package/src/cli.ts +18 -0
  125. package/src/coding/architecture-card.test.ts +544 -0
  126. package/src/coding/architecture-card.ts +687 -0
  127. package/src/coding/architecture-surfaces.test.ts +579 -0
  128. package/src/coding/architecture-surfaces.ts +457 -0
  129. package/src/maintenance/namespace-maintenance-fanout.test.ts +595 -0
  130. package/src/maintenance/namespace-maintenance-fanout.ts +318 -0
  131. package/src/maintenance/namespace-planner.ts +74 -16
  132. package/src/operator-toolkit.ts +25 -0
  133. package/src/orchestrator.ts +144 -0
  134. package/src/recall-pipeline-stages.test.ts +269 -0
  135. package/src/recall-pipeline-stages.ts +294 -0
  136. package/src/storage.ts +6 -20
  137. package/src/structured-attributes.ts +39 -0
  138. package/dist/chunk-473JIN2U.js.map +0 -1
  139. package/dist/chunk-B43NZNMG.js.map +0 -1
  140. package/dist/chunk-BLIWOONZ.js.map +0 -1
  141. package/dist/chunk-CCWHPGT4.js.map +0 -1
  142. package/dist/chunk-FUCUR2OZ.js.map +0 -1
  143. package/dist/chunk-KFBOZYME.js.map +0 -1
  144. package/dist/chunk-NN7QYW5W.js.map +0 -1
  145. package/dist/chunk-RVYD6LR3.js.map +0 -1
  146. package/dist/chunk-S2OU5DZY.js.map +0 -1
  147. /package/dist/{chunk-B4XVLHJA.js.map → chunk-2N6UNJSX.js.map} +0 -0
  148. /package/dist/{chunk-4N3TFFPH.js.map → chunk-54PVJDO5.js.map} +0 -0
  149. /package/dist/{chunk-PH3HOKYW.js.map → chunk-67MMWC74.js.map} +0 -0
  150. /package/dist/{chunk-STOEE37X.js.map → chunk-BKAMHZYR.js.map} +0 -0
  151. /package/dist/{chunk-2SJCWLQD.js.map → chunk-CWE74HRG.js.map} +0 -0
  152. /package/dist/{chunk-VX6OBUDW.js.map → chunk-GR77Z2BM.js.map} +0 -0
  153. /package/dist/{chunk-CTOQEZSN.js.map → chunk-LXIEXSHU.js.map} +0 -0
  154. /package/dist/{chunk-GS55WYRL.js.map → chunk-PHZKALOE.js.map} +0 -0
  155. /package/dist/{chunk-6O6A6YUO.js.map → chunk-RGNEARXW.js.map} +0 -0
  156. /package/dist/{chunk-4FE2K57M.js.map → chunk-RTFAZOIR.js.map} +0 -0
  157. /package/dist/{chunk-UTYBJR7M.js.map → chunk-SB6CQTKP.js.map} +0 -0
  158. /package/dist/{chunk-2KAYTPPT.js.map → chunk-TYF3D4MS.js.map} +0 -0
  159. /package/dist/{chunk-WIHPNY65.js.map → chunk-UD5OKH4J.js.map} +0 -0
  160. /package/dist/{chunk-QANVLERJ.js.map → chunk-UPIBE2DK.js.map} +0 -0
  161. /package/dist/{chunk-ZPTISBQU.js.map → chunk-X5J3JZR3.js.map} +0 -0
  162. /package/dist/{chunk-OV4D5T7V.js.map → chunk-X7RLU5CR.js.map} +0 -0
  163. /package/dist/{chunk-DCWIQFNA.js.map → chunk-ZUDM75KG.js.map} +0 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Namespace maintenance fanout coordinator (issue #1500).
3
+ *
4
+ * Builds on the namespace-aware maintenance planner (#1499 / #1517) to fan out
5
+ * the REMAINING background maintenance jobs — dreams, pattern reinforcement,
6
+ * governance/lifecycle, contradiction scans, semantic/causal consolidation,
7
+ * graph decay, fact archival, tier migration — across all maintained
8
+ * namespaces.
9
+ *
10
+ * The planner (`namespace-planner.ts`) already handles namespace discovery
11
+ * (configured + catalog), per-kind gating, cycle budgeting, per-job+namespace
12
+ * locking, status recording, and catalog maintenance timestamps. QMD
13
+ * maintenance was wired through it in #1517. This module standardizes the
14
+ * per-job adapter pattern so every other maintenance job can be fanned out
15
+ * with one call, and aggregates per-namespace health for doctor/dashboard.
16
+ *
17
+ * Design contract (issue #1500 compatibility requirements):
18
+ * - Existing direct `runJob({ namespace })` calls continue to work unchanged.
19
+ * - Namespaces disabled: fanout is a no-op (the planner returns only the
20
+ * default namespace; jobs run exactly as before).
21
+ * - `maintenanceNamespaceFanoutEnabled: false`: fanout is a no-op.
22
+ * - A failure in one namespace must not abort other namespaces (the planner
23
+ * already isolates per-namespace failures).
24
+ * - Per-namespace locks prevent duplicate concurrent runs (planner-provided).
25
+ */
26
+
27
+ import type { NamespaceCatalog } from "../namespaces/catalog.js";
28
+ import type { PluginConfig } from "../types.js";
29
+ import {
30
+ planNamespaceMaintenance,
31
+ readNamespaceMaintenanceLastRanStatuses,
32
+ readNamespaceMaintenanceStatuses,
33
+ runNamespaceMaintenancePlan,
34
+ type NamespaceMaintenanceCandidate,
35
+ type NamespaceMaintenanceRunStatus,
36
+ type NamespaceMaintenanceSummary,
37
+ } from "./namespace-planner.js";
38
+
39
+ /**
40
+ * Standard maintenance job names fanned out across namespaces.
41
+ *
42
+ * These strings are used as the `jobName` key in:
43
+ * - per-namespace status files (`state/namespace-maintenance-status/<job>/...`)
44
+ * - catalog `lastMaintenanceAt[jobName]` timestamps
45
+ * - per-job+namespace lock files (`state/maintenance-locks/<job>/...`)
46
+ *
47
+ * Keeping them centralized ensures the doctor, CLI, and dashboard all report
48
+ * against the same keys.
49
+ */
50
+ export const NAMESPACE_MAINTENANCE_JOBS = [
51
+ "qmd",
52
+ "pattern-reinforcement",
53
+ "contradiction-scan",
54
+ "semantic-consolidation",
55
+ "governance",
56
+ "lifecycle",
57
+ "graph-decay",
58
+ "fact-archival",
59
+ "tier-migration",
60
+ ] as const;
61
+
62
+ export type NamespaceMaintenanceStandardJob =
63
+ (typeof NAMESPACE_MAINTENANCE_JOBS)[number];
64
+
65
+ /**
66
+ * Context passed to a fanout job runner. The runner receives the per-namespace
67
+ * candidate (namespace name, kind, storage dir) and a storage resolver that
68
+ * the orchestrator wires to `storageRouter.storageFor(namespace)`.
69
+ *
70
+ * The storage resolver is intentionally typed as `unknown` here so this module
71
+ * does not depend on the `StorageManager` class — the orchestrator provides the
72
+ * typed resolver. This keeps the fanout module free of orchestrator/storage
73
+ * imports and unit-testable with a stub.
74
+ */
75
+ export interface NamespaceMaintenanceFanoutRunnerContext {
76
+ config: PluginConfig;
77
+ candidate: NamespaceMaintenanceCandidate;
78
+ resolveStorage: (namespace: string) => Promise<unknown>;
79
+ }
80
+
81
+ /**
82
+ * Result returned by a fanout job runner. `itemCount` is optional and
83
+ * domain-specific (e.g. memories scanned, edges decayed, embeddings updated).
84
+ *
85
+ * `skipped`/`skipReason`: when a runner performs no work for a namespace
86
+ * (e.g. the job's own cadence gate throttled it), set `skipped: true`. The
87
+ * planner records the namespace as `state: "skipped"` and does NOT touch
88
+ * the catalog's `lastMaintenanceAt`, so a throttled namespace is not
89
+ * falsely reported as maintained.
90
+ */
91
+ export interface NamespaceMaintenanceFanoutRunnerResult {
92
+ itemCount?: number;
93
+ skipped?: boolean;
94
+ skipReason?: string;
95
+ }
96
+
97
+ export type NamespaceMaintenanceFanoutRunner = (
98
+ ctx: NamespaceMaintenanceFanoutRunnerContext,
99
+ ) => Promise<NamespaceMaintenanceFanoutRunnerResult | undefined>;
100
+
101
+ export interface RunNamespaceMaintenanceFanoutOptions {
102
+ config: PluginConfig;
103
+ catalog?: NamespaceCatalog;
104
+ jobName: string;
105
+ runner: NamespaceMaintenanceFanoutRunner;
106
+ resolveStorage: (namespace: string) => Promise<unknown>;
107
+ /**
108
+ * When `false`, skip fanout entirely and return a zero-summary without
109
+ * touching the planner or locks. This lets callers gate fanout on
110
+ * per-job config (e.g. `semanticConsolidationEnabled`) without repeating
111
+ * the namespace-discovery logic.
112
+ */
113
+ enabled?: boolean;
114
+ }
115
+
116
+ /**
117
+ * Fan out a single maintenance job across all maintained namespaces.
118
+ *
119
+ * This is the primary entry point for wiring a maintenance job through the
120
+ * namespace-aware planner. It:
121
+ * 1. Plans which namespaces should be maintained (configured + catalog,
122
+ * budgeted, kind-gated).
123
+ * 2. Runs the job per-namespace through `runNamespaceMaintenancePlan`, which
124
+ * acquires per-job+namespace locks, records status files, and touches the
125
+ * catalog's `lastMaintenanceAt`.
126
+ *
127
+ * When namespaces are disabled or fanout is off, the planner returns only the
128
+ * default namespace, so the job runs exactly once against default storage —
129
+ * preserving single-user behavior.
130
+ */
131
+ export async function runNamespaceMaintenanceFanout(
132
+ options: RunNamespaceMaintenanceFanoutOptions,
133
+ ): Promise<NamespaceMaintenanceSummary> {
134
+ if (options.enabled === false) {
135
+ return {
136
+ jobName: options.jobName,
137
+ generatedAt: new Date().toISOString(),
138
+ ran: 0,
139
+ skipped: 0,
140
+ failed: 0,
141
+ statuses: [],
142
+ };
143
+ }
144
+
145
+ const plan = await planNamespaceMaintenance(options.config, {
146
+ jobName: options.jobName,
147
+ catalog: options.catalog,
148
+ });
149
+
150
+ return runNamespaceMaintenancePlan(
151
+ options.config,
152
+ plan,
153
+ async (candidate) => {
154
+ return options.runner({
155
+ config: options.config,
156
+ candidate,
157
+ resolveStorage: options.resolveStorage,
158
+ });
159
+ },
160
+ options.catalog,
161
+ );
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Health summary for doctor / dashboard / CLI
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export interface NamespaceMaintenanceJobHealth {
169
+ jobName: string;
170
+ ran: number;
171
+ skipped: number;
172
+ failed: number;
173
+ lastRunAt: string | null;
174
+ namespaces: NamespaceMaintenanceRunStatus[];
175
+ }
176
+
177
+ export interface NamespaceMaintenanceHealthSummary {
178
+ generatedAt: string;
179
+ fanoutEnabled: boolean;
180
+ namespacesEnabled: boolean;
181
+ maxNamespacesPerCycle: number;
182
+ jobs: NamespaceMaintenanceJobHealth[];
183
+ totalRan: number;
184
+ totalSkipped: number;
185
+ totalFailed: number;
186
+ }
187
+
188
+ /**
189
+ * Read all per-namespace maintenance status files and aggregate them into a
190
+ * health summary suitable for `remnic doctor` and the admin dashboard.
191
+ *
192
+ * This is a pure read operation — it never runs maintenance or acquires locks.
193
+ */
194
+ export async function summarizeNamespaceMaintenanceHealth(
195
+ config: PluginConfig,
196
+ ): Promise<NamespaceMaintenanceHealthSummary> {
197
+ const generatedAt = new Date().toISOString();
198
+ const statuses = await readNamespaceMaintenanceStatuses(config);
199
+ // Merge last-successful-run records so lastRunAt reflects the most recent
200
+ // successful maintenance, not just the latest (possibly skipped/failed)
201
+ // outcome. The latest status file is overwritten on every run; without
202
+ // this merge a namespace that ran then got budget-skipped shows ran=0 /
203
+ // lastRunAt=null (review #1622: preserve last successful run).
204
+ const lastRanStatuses = await readNamespaceMaintenanceLastRanStatuses(config);
205
+ const lastRanByJobNs = new Map<string, NamespaceMaintenanceRunStatus>();
206
+ for (const lr of lastRanStatuses) {
207
+ lastRanByJobNs.set(`${lr.jobName}\u0000${lr.namespace}`, lr);
208
+ }
209
+
210
+ const byJob = new Map<string, NamespaceMaintenanceRunStatus[]>();
211
+ for (const status of statuses) {
212
+ const bucket = byJob.get(status.jobName);
213
+ if (bucket) {
214
+ bucket.push(status);
215
+ } else {
216
+ byJob.set(status.jobName, [status]);
217
+ }
218
+ }
219
+
220
+ const knownJobs = new Set<string>(NAMESPACE_MAINTENANCE_JOBS);
221
+ // Include any job names seen in status files that are not in the standard
222
+ // set (e.g. custom jobs registered by the orchestrator) so the doctor does
223
+ // not silently hide them.
224
+ for (const jobName of byJob.keys()) {
225
+ knownJobs.add(jobName);
226
+ }
227
+
228
+ const jobs: NamespaceMaintenanceJobHealth[] = [];
229
+ let totalRan = 0;
230
+ let totalSkipped = 0;
231
+ let totalFailed = 0;
232
+
233
+ for (const jobName of [...knownJobs].sort()) {
234
+ const jobStatuses = byJob.get(jobName) ?? [];
235
+ const ran = jobStatuses.filter((s) => s.state === "ran").length;
236
+ const skipped = jobStatuses.filter((s) => s.state === "skipped").length;
237
+ const failed = jobStatuses.filter((s) => s.state === "failed").length;
238
+ // Consider BOTH the latest outcome and the last successful run so a
239
+ // namespace that ran then was later skipped still reports lastRunAt.
240
+ const candidates: string[] = [];
241
+ for (const st of jobStatuses) {
242
+ if (typeof st.completedAt === "string") candidates.push(st.completedAt);
243
+ const lr = lastRanByJobNs.get(`${jobName}\u0000${st.namespace}`);
244
+ if (lr && typeof lr.completedAt === "string") candidates.push(lr.completedAt);
245
+ }
246
+ const lastRunAt = candidates.sort().at(-1) ?? null;
247
+
248
+ totalRan += ran;
249
+ totalSkipped += skipped;
250
+ totalFailed += failed;
251
+
252
+ jobs.push({
253
+ jobName,
254
+ ran,
255
+ skipped,
256
+ failed,
257
+ lastRunAt,
258
+ namespaces: jobStatuses,
259
+ });
260
+ }
261
+
262
+ return {
263
+ generatedAt,
264
+ fanoutEnabled: config.maintenanceNamespaceFanoutEnabled !== false,
265
+ namespacesEnabled: config.namespacesEnabled,
266
+ maxNamespacesPerCycle: config.maintenanceMaxNamespacesPerCycle,
267
+ jobs,
268
+ totalRan,
269
+ totalSkipped,
270
+ totalFailed,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Format the health summary as human-readable text for the CLI.
276
+ */
277
+ export function formatNamespaceMaintenanceHealthText(
278
+ summary: NamespaceMaintenanceHealthSummary,
279
+ ): string {
280
+ const lines: string[] = [
281
+ "=== Namespace Maintenance ===",
282
+ "",
283
+ ` fanout: ${summary.fanoutEnabled ? "enabled" : "disabled"}`,
284
+ ` namespaces: ${summary.namespacesEnabled ? "enabled" : "disabled"}`,
285
+ ` max/cycle: ${summary.maxNamespacesPerCycle}`,
286
+ ` total ran: ${summary.totalRan}`,
287
+ ` total skipped: ${summary.totalSkipped}`,
288
+ ` total failed: ${summary.totalFailed}`,
289
+ "",
290
+ ];
291
+
292
+ if (summary.totalRan === 0 && summary.totalSkipped === 0 && summary.totalFailed === 0) {
293
+ lines.push(" (no maintenance status recorded yet)");
294
+ return lines.join("\n");
295
+ }
296
+
297
+ lines.push(" Per-job breakdown:");
298
+ for (const job of summary.jobs) {
299
+ const parts = [
300
+ `ran=${job.ran}`,
301
+ `skipped=${job.skipped}`,
302
+ `failed=${job.failed}`,
303
+ ];
304
+ if (job.lastRunAt) {
305
+ parts.push(`last=${job.lastRunAt}`);
306
+ }
307
+ lines.push(` ${job.jobName}: ${parts.join(", ")}`);
308
+ // Surface failed namespaces with their reason/error so operators know
309
+ // WHAT to fix without rerunning with --json (review #1622).
310
+ const failedNs = job.namespaces.filter((n) => n.state === "failed");
311
+ for (const ns of failedNs) {
312
+ const detail = ns.error ? `: ${ns.error}` : "";
313
+ lines.push(` ! ${ns.namespace} failed (reason=${ns.reason ?? "unknown"})${detail}`);
314
+ }
315
+ }
316
+
317
+ return lines.join("\n");
318
+ }
@@ -213,7 +213,14 @@ export async function planNamespaceMaintenance(
213
213
  options: NamespaceMaintenancePlannerOptions
214
214
  ): Promise<NamespaceMaintenancePlan> {
215
215
  const generatedAt = (options.now ?? new Date()).toISOString();
216
- const configured = configuredNamespaces(config);
216
+ // When namespaces are disabled, storageFor() collapses every namespace name
217
+ // to config.memoryDir. Seeding all configured namespaces (default + shared +
218
+ // policies) would make a mutating maintenance job process the SAME corpus
219
+ // once per configured name. The #1500 contract is "namespaces disabled:
220
+ // maintain the current default storage only," so collapse to the default.
221
+ const configured = config.namespacesEnabled
222
+ ? configuredNamespaces(config)
223
+ : [config.defaultNamespace.trim()].filter(Boolean);
217
224
  const byNamespace = new Map<string, NamespaceMaintenanceCandidate>();
218
225
  const skipped: NamespaceMaintenanceSkippedNamespace[] = [];
219
226
 
@@ -640,10 +647,44 @@ export async function readNamespaceMaintenanceStatuses(config: PluginConfig): Pr
640
647
  });
641
648
  }
642
649
 
650
+ /**
651
+ * Read the last SUCCESSFUL run status per job+namespace (the
652
+ * `<namespace>.last-ran.json` files written only when state === "ran").
653
+ *
654
+ * The latest status file (`<namespace>.json`) is overwritten on every run,
655
+ * so after a successful run followed by a skip (budget/lock/cadence) the
656
+ * latest file shows "skipped" and the prior success is invisible. Merging
657
+ * these last-ran records into the health summary lets `lastRunAt` and run
658
+ * history reflect the most recent successful maintenance (review #1622).
659
+ */
660
+ export async function readNamespaceMaintenanceLastRanStatuses(
661
+ config: PluginConfig,
662
+ ): Promise<NamespaceMaintenanceRunStatus[]> {
663
+ return (await readLastRanStatusFiles(config)).sort((a, b) => {
664
+ const byJob = a.jobName.localeCompare(b.jobName);
665
+ if (byJob !== 0) return byJob;
666
+ return a.namespace.localeCompare(b.namespace);
667
+ });
668
+ }
669
+
670
+ export type NamespaceMaintenancePlanRunnerResult = {
671
+ itemCount?: number;
672
+ /**
673
+ * When `true`, the runner performed NO work for this namespace (e.g. the
674
+ * job's own cadence gate throttled it). The planner records the namespace
675
+ * as `state: "skipped"` with the given reason and does NOT touch the
676
+ * catalog's `lastMaintenanceAt`, so a throttled namespace is not falsely
677
+ * reported as maintained. Without this signal a runner that resolves
678
+ * without throwing is always recorded as `state: "ran"`.
679
+ */
680
+ skipped?: boolean;
681
+ skipReason?: string;
682
+ } | undefined;
683
+
643
684
  export async function runNamespaceMaintenancePlan(
644
685
  config: PluginConfig,
645
686
  plan: NamespaceMaintenancePlan,
646
- runner: (candidate: NamespaceMaintenanceCandidate) => Promise<{ itemCount?: number } | undefined>,
687
+ runner: (candidate: NamespaceMaintenanceCandidate) => Promise<NamespaceMaintenancePlanRunnerResult>,
647
688
  catalog?: NamespaceCatalog
648
689
  ): Promise<NamespaceMaintenanceSummary> {
649
690
  const statuses: NamespaceMaintenanceRunStatus[] = [];
@@ -683,20 +724,37 @@ export async function runNamespaceMaintenancePlan(
683
724
  try {
684
725
  const result = await withNamespaceMaintenanceLockHeartbeat(config, lock, () => runner(candidate));
685
726
  const completedAt = new Date().toISOString();
686
- const status: NamespaceMaintenanceRunStatus = {
687
- namespace: candidate.namespace,
688
- jobName: plan.jobName,
689
- state: "ran",
690
- startedAt,
691
- completedAt,
692
- itemCount: result?.itemCount,
693
- };
694
- statuses.push(status);
695
- await recordNamespaceMaintenanceStatusSafely(config, status);
696
- try {
697
- await catalog?.markMaintenance(candidate.namespace, plan.jobName, new Date(completedAt));
698
- } catch {
699
- // Catalog maintenance touches are best-effort status metadata.
727
+ if (result?.skipped) {
728
+ // The runner performed no work (e.g. the job's own cadence gate
729
+ // throttled this namespace). Record skipped WITHOUT touching the
730
+ // catalog's lastMaintenanceAt so a throttled namespace is not
731
+ // falsely reported as maintained.
732
+ const status: NamespaceMaintenanceRunStatus = {
733
+ namespace: candidate.namespace,
734
+ jobName: plan.jobName,
735
+ state: "skipped",
736
+ reason: (result.skipReason ?? "throttled") as NamespaceMaintenanceSkipReason,
737
+ startedAt,
738
+ completedAt,
739
+ };
740
+ statuses.push(status);
741
+ await recordNamespaceMaintenanceStatusSafely(config, status);
742
+ } else {
743
+ const status: NamespaceMaintenanceRunStatus = {
744
+ namespace: candidate.namespace,
745
+ jobName: plan.jobName,
746
+ state: "ran",
747
+ startedAt,
748
+ completedAt,
749
+ itemCount: result?.itemCount,
750
+ };
751
+ statuses.push(status);
752
+ await recordNamespaceMaintenanceStatusSafely(config, status);
753
+ try {
754
+ await catalog?.markMaintenance(candidate.namespace, plan.jobName, new Date(completedAt));
755
+ } catch {
756
+ // Catalog maintenance touches are best-effort status metadata.
757
+ }
700
758
  }
701
759
  } catch (error) {
702
760
  const completedAt = new Date().toISOString();
@@ -12,6 +12,7 @@ import {
12
12
  } from "./native-knowledge.js";
13
13
  import { StorageManager } from "./storage.js";
14
14
  import { listNamespaces } from "./namespaces/migrate.js";
15
+ import { summarizeNamespaceMaintenanceHealth } from "./maintenance/namespace-maintenance-fanout.js";
15
16
  import {
16
17
  createEvalBaselineSnapshot,
17
18
  getEvalHarnessStatus,
@@ -1301,6 +1302,30 @@ export async function runOperatorDoctor(options: OperatorDoctorOptions): Promise
1301
1302
  // state and is never an error.
1302
1303
  checks.push(await summarizeObservationThroughput(config.memoryDir));
1303
1304
 
1305
+ // Namespace maintenance fanout status (issue #1500).
1306
+ // Reports per-job+namespace maintenance outcomes (ran/skipped/failed) so
1307
+ // operators can verify that dynamic project/team namespaces are being
1308
+ // maintained. Informational: never errors on its own — a namespace that has
1309
+ // never been maintained is the expected cold-install state.
1310
+ const namespaceMaintenanceHealth = await summarizeNamespaceMaintenanceHealth(config);
1311
+ checks.push({
1312
+ key: "namespace_maintenance",
1313
+ status: namespaceMaintenanceHealth.totalFailed > 0 ? "warn" : "ok",
1314
+ summary:
1315
+ namespaceMaintenanceHealth.totalRan === 0 && namespaceMaintenanceHealth.totalFailed === 0
1316
+ ? "No namespace maintenance recorded yet (expected for a fresh install)."
1317
+ : `${namespaceMaintenanceHealth.totalRan} namespace maintenance run(s)` +
1318
+ (namespaceMaintenanceHealth.totalFailed > 0
1319
+ ? `, ${namespaceMaintenanceHealth.totalFailed} failure(s) across ${namespaceMaintenanceHealth.jobs.filter((j) => j.failed > 0).length} job(s)`
1320
+ : "") +
1321
+ ".",
1322
+ remediation:
1323
+ namespaceMaintenanceHealth.totalFailed > 0
1324
+ ? "Run `remnic namespaces maintenance` for per-namespace details."
1325
+ : undefined,
1326
+ details: namespaceMaintenanceHealth,
1327
+ });
1328
+
1304
1329
  const summary = checks.reduce(
1305
1330
  (acc, check) => {
1306
1331
  acc[check.status] += 1;
@@ -318,7 +318,14 @@ import {
318
318
  runNamespaceMaintenanceBatchPlan,
319
319
  type NamespaceMaintenancePlan,
320
320
  type NamespaceMaintenanceSkipReason,
321
+ type NamespaceMaintenanceSummary,
321
322
  } from "./maintenance/namespace-planner.js";
323
+ import {
324
+ runNamespaceMaintenanceFanout,
325
+ summarizeNamespaceMaintenanceHealth,
326
+ type NamespaceMaintenanceFanoutRunnerContext,
327
+ type NamespaceMaintenanceHealthSummary,
328
+ } from "./maintenance/namespace-maintenance-fanout.js";
322
329
  import {
323
330
  namespaceIdentityFromToken,
324
331
  namespaceIdentityToken,
@@ -2483,6 +2490,42 @@ export class Orchestrator {
2483
2490
  return plan.namespaces.map((candidate) => candidate.namespace);
2484
2491
  }
2485
2492
 
2493
+ /**
2494
+ * Fan out a maintenance job across all maintained namespaces (issue #1500).
2495
+ *
2496
+ * Delegates to the namespace-maintenance-fanout coordinator, which plans
2497
+ * namespace discovery (configured + catalog), applies the cycle budget,
2498
+ * acquires per-job+namespace locks, records status files, and touches the
2499
+ * catalog's `lastMaintenanceAt`. The runner receives a per-namespace
2500
+ * candidate and a storage resolver wired to `this.getStorage(namespace)`.
2501
+ *
2502
+ * When namespaces are disabled the planner returns only the default
2503
+ * namespace, so the job runs exactly once — preserving single-user behavior.
2504
+ */
2505
+ async runNamespaceMaintenanceFanoutForJob(
2506
+ jobName: string,
2507
+ runner: (ctx: NamespaceMaintenanceFanoutRunnerContext) => Promise<{ itemCount?: number } | undefined>,
2508
+ options: { enabled?: boolean } = {},
2509
+ ): Promise<NamespaceMaintenanceSummary> {
2510
+ return runNamespaceMaintenanceFanout({
2511
+ config: this.config,
2512
+ catalog: this.namespaceCatalog,
2513
+ jobName,
2514
+ runner,
2515
+ resolveStorage: (namespace) => this.getStorage(namespace),
2516
+ enabled: options.enabled,
2517
+ });
2518
+ }
2519
+
2520
+ /**
2521
+ * Read-only namespace maintenance health summary for doctor / dashboard /
2522
+ * CLI (issue #1500). Aggregates all per-namespace maintenance status files
2523
+ * into one report without running any maintenance.
2524
+ */
2525
+ async readNamespaceMaintenanceHealth(): Promise<NamespaceMaintenanceHealthSummary> {
2526
+ return summarizeNamespaceMaintenanceHealth(this.config);
2527
+ }
2528
+
2486
2529
  private buildConfiguredQmdSearchOptions(
2487
2530
  queryText: string,
2488
2531
  ): SearchQueryOptions | undefined {
@@ -3998,6 +4041,107 @@ export class Orchestrator {
3998
4041
  return { ran: true, result, namespace: cadenceKey };
3999
4042
  }
4000
4043
 
4044
+ /**
4045
+ * Fan out pattern reinforcement across all maintained namespaces (issue #1500).
4046
+ * Delegates per-namespace execution to {@link runPatternReinforcement} while
4047
+ * the planner handles discovery, budgeting, locking, and status recording.
4048
+ * When namespaces are disabled, runs once against default storage.
4049
+ */
4050
+ async runPatternReinforcementFanout(options: {
4051
+ force?: boolean;
4052
+ } = {}): Promise<NamespaceMaintenanceSummary> {
4053
+ return this.runNamespaceMaintenanceFanoutForJob(
4054
+ "pattern-reinforcement",
4055
+ async (ctx) => {
4056
+ const result = await this.runPatternReinforcement({
4057
+ namespace: ctx.candidate.namespace,
4058
+ force: options.force,
4059
+ });
4060
+ // runPatternReinforcement has its own per-namespace cadence gate
4061
+ // (lastPatternReinforcementAtByNs). When it throttles (ran:false),
4062
+ // signal skip so the planner records state:"skipped" and does NOT
4063
+ // touch lastMaintenanceAt — otherwise a throttled namespace would
4064
+ // look maintained while pattern reinforcement never ran (#1500
4065
+ // review: cadence-skip accuracy).
4066
+ if (!result.ran) {
4067
+ return {
4068
+ skipped: true,
4069
+ skipReason: result.skippedReason ?? "throttled",
4070
+ };
4071
+ }
4072
+ return result.result
4073
+ ? { itemCount: result.result.clustersFound }
4074
+ : { itemCount: 0 };
4075
+ },
4076
+ { enabled: this.config.patternReinforcementEnabled },
4077
+ );
4078
+ }
4079
+
4080
+ /**
4081
+ * Fan out lifecycle/governance policy across all maintained namespaces
4082
+ * (issue #1500). Each namespace gets its own lifecycle pass against its
4083
+ * namespace-scoped storage. When namespaces are disabled, runs once against
4084
+ * default storage.
4085
+ */
4086
+ async runLifecyclePolicyFanout(): Promise<NamespaceMaintenanceSummary> {
4087
+ return this.runNamespaceMaintenanceFanoutForJob(
4088
+ "lifecycle",
4089
+ async (ctx) => {
4090
+ const storage = await this.getStorage(ctx.candidate.namespace);
4091
+ const corpus = await storage.readAllMemories();
4092
+ const assessed = await this.runLifecyclePolicyPass(corpus, storage);
4093
+ return { itemCount: assessed };
4094
+ },
4095
+ { enabled: this.config.lifecyclePolicyEnabled },
4096
+ );
4097
+ }
4098
+
4099
+ /**
4100
+ * Fan out semantic consolidation across all maintained namespaces (issue #1500).
4101
+ * Each namespace gets its own consolidation pass against its namespace-scoped
4102
+ * storage. When namespaces are disabled, runs once against default storage.
4103
+ */
4104
+ async runSemanticConsolidationFanout(options: {
4105
+ dryRun?: boolean;
4106
+ } = {}): Promise<NamespaceMaintenanceSummary> {
4107
+ return this.runNamespaceMaintenanceFanoutForJob(
4108
+ "semantic-consolidation",
4109
+ async (ctx) => {
4110
+ const storage = await this.getStorage(ctx.candidate.namespace);
4111
+ const result = await this.runSemanticConsolidation({
4112
+ dryRun: options.dryRun,
4113
+ thresholdOverride: undefined,
4114
+ force: true,
4115
+ storage,
4116
+ });
4117
+ return { itemCount: result.clustersFound };
4118
+ },
4119
+ { enabled: this.config.semanticConsolidationEnabled },
4120
+ );
4121
+ }
4122
+
4123
+ /**
4124
+ * Fan out deep-sleep governance across all maintained namespaces (issue #1500).
4125
+ * Each namespace gets its own governance scan against its namespace-scoped
4126
+ * storage. When namespaces are disabled, runs once against default storage.
4127
+ */
4128
+ async runDeepSleepGovernanceFanout(options: {
4129
+ dryRun?: boolean;
4130
+ } = {}): Promise<NamespaceMaintenanceSummary> {
4131
+ return this.runNamespaceMaintenanceFanoutForJob(
4132
+ "governance",
4133
+ async (ctx) => {
4134
+ const storage = await this.getStorage(ctx.candidate.namespace);
4135
+ const result = await this.runDeepSleepGovernanceNow({
4136
+ dryRun: options.dryRun,
4137
+ storage,
4138
+ });
4139
+ return { itemCount: result.scannedMemories };
4140
+ },
4141
+ { enabled: this.config.dreamsPhases.deepSleep.enabled },
4142
+ );
4143
+ }
4144
+
4001
4145
  private async autoRegisterGraphEdgeDecayCron(): Promise<void> {
4002
4146
  const home = resolveHomeDir();
4003
4147
  const jobsPath = path.join(home, ".openclaw", "cron", "jobs.json");