@mulmoclaude/core 3.0.1 → 3.2.0

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 (73) hide show
  1. package/assets/helps/collection-skills.md +72 -8
  2. package/assets/helps/custom-view.md +9 -1
  3. package/assets/helps/error-recovery.md +36 -0
  4. package/assets/helps/feeds.md +6 -4
  5. package/dist/calendarGrid-Csy2rpjp.js.map +1 -1
  6. package/dist/calendarGrid-p8K-7cuB.cjs.map +1 -1
  7. package/dist/collection/core/presentCollection.d.ts +49 -0
  8. package/dist/collection/core/schema.d.ts +16 -1
  9. package/dist/collection/index.cjs +56 -7
  10. package/dist/collection/index.cjs.map +1 -1
  11. package/dist/collection/index.js +53 -8
  12. package/dist/collection/index.js.map +1 -1
  13. package/dist/collection/registry/server/importWriter.d.ts +2 -1
  14. package/dist/collection/registry/server/index.cjs +31 -10
  15. package/dist/collection/registry/server/index.cjs.map +1 -1
  16. package/dist/collection/registry/server/index.js +30 -9
  17. package/dist/collection/registry/server/index.js.map +1 -1
  18. package/dist/collection/server/host.d.ts +34 -4
  19. package/dist/collection/server/index.cjs +4 -2
  20. package/dist/collection/server/index.d.ts +1 -1
  21. package/dist/collection/server/index.js +4 -3
  22. package/dist/collection/server/manageTool.d.ts +19 -0
  23. package/dist/collection/server/schemaDocs.d.ts +5 -1
  24. package/dist/collection-watchers/config.d.ts +15 -2
  25. package/dist/collection-watchers/index.cjs +310 -198
  26. package/dist/collection-watchers/index.cjs.map +1 -1
  27. package/dist/collection-watchers/index.js +310 -198
  28. package/dist/collection-watchers/index.js.map +1 -1
  29. package/dist/collection-watchers/reconciler.d.ts +1 -5
  30. package/dist/collection-watchers/watcher.d.ts +31 -26
  31. package/dist/{discovery-4Z-ZWFam.js → discovery-CC7kuwHf.js} +21 -7
  32. package/dist/discovery-CC7kuwHf.js.map +1 -0
  33. package/dist/{discovery-CtiZMlsX.cjs → discovery-CDOjfctD.cjs} +33 -7
  34. package/dist/discovery-CDOjfctD.cjs.map +1 -0
  35. package/dist/feeds/server/host.d.ts +12 -0
  36. package/dist/feeds/server/index.cjs +27 -6
  37. package/dist/feeds/server/index.cjs.map +1 -1
  38. package/dist/feeds/server/index.d.ts +1 -1
  39. package/dist/feeds/server/index.js +27 -7
  40. package/dist/feeds/server/index.js.map +1 -1
  41. package/dist/feeds/server/scheduledRefresh.d.ts +16 -1
  42. package/dist/files/index.cjs +6 -5
  43. package/dist/files/index.cjs.map +1 -1
  44. package/dist/files/index.d.ts +1 -0
  45. package/dist/files/index.js +2 -2
  46. package/dist/files/root.d.ts +15 -0
  47. package/dist/google/index.cjs +3 -3
  48. package/dist/google/index.cjs.map +1 -1
  49. package/dist/google/index.js +2 -2
  50. package/dist/remote-host/index.cjs +3 -1
  51. package/dist/remote-host/index.d.ts +30 -0
  52. package/dist/remote-host/index.js +2 -2
  53. package/dist/remote-host/server/index.cjs +1 -1
  54. package/dist/remote-host/server/index.js +1 -1
  55. package/dist/{remote-host-Dba4lF3Z.js → remote-host-CFnl1I-L.js} +35 -2
  56. package/dist/{remote-host-Dba4lF3Z.js.map → remote-host-CFnl1I-L.js.map} +1 -1
  57. package/dist/{remote-host-CtQjagPt.cjs → remote-host-EQPlccB9.cjs} +46 -1
  58. package/dist/{remote-host-CtQjagPt.cjs.map → remote-host-EQPlccB9.cjs.map} +1 -1
  59. package/dist/{atomic-DPpdrJzO.js → root-BMroU_mB.js} +21 -2
  60. package/dist/root-BMroU_mB.js.map +1 -0
  61. package/dist/{atomic-DhUk8uiM.cjs → root-rPH6FGDT.cjs} +26 -1
  62. package/dist/root-rPH6FGDT.cjs.map +1 -0
  63. package/dist/{server-CedIocRS.js → server-AA2kfDfk.js} +137 -42
  64. package/dist/server-AA2kfDfk.js.map +1 -0
  65. package/dist/{server-DOZFzx5l.cjs → server-WU35UYlF.cjs} +138 -43
  66. package/dist/server-WU35UYlF.cjs.map +1 -0
  67. package/package.json +1 -1
  68. package/dist/atomic-DPpdrJzO.js.map +0 -1
  69. package/dist/atomic-DhUk8uiM.cjs.map +0 -1
  70. package/dist/discovery-4Z-ZWFam.js.map +0 -1
  71. package/dist/discovery-CtiZMlsX.cjs.map +0 -1
  72. package/dist/server-CedIocRS.js.map +0 -1
  73. package/dist/server-DOZFzx5l.cjs.map +0 -1
@@ -1,8 +1,9 @@
1
+ import { t as canonicalRoot } from "../root-BMroU_mB.js";
1
2
  import { k as fieldText } from "../calendarGrid-Csy2rpjp.js";
2
3
  import { E as whenMatches, f as itemIsDone } from "../promptSafety-ACXw_2Yu.js";
3
4
  import "../collection/index.js";
4
- import { $ as publishCollectionChange, K as collectionChangePayload, Q as peekWorkspaceRoot, d as storeFor, n as discoverCollections, r as loadCollection, z as itemFilePath } from "../discovery-4Z-ZWFam.js";
5
- import { d as isTriggerDue, f as maybeSpawnSuccessor } from "../server-CedIocRS.js";
5
+ import { K as collectionChangePayload, Q as peekWorkspaceRoot, d as storeFor, et as publishCollectionChange, n as discoverCollections, r as loadCollection, z as itemFilePath } from "../discovery-CC7kuwHf.js";
6
+ import { d as isTriggerDue, f as maybeSpawnSuccessor } from "../server-AA2kfDfk.js";
6
7
  import { d as publish, m as updateForPlugin, n as clear, s as listAll } from "../notifier-DhnwR82U.js";
7
8
  import { access } from "node:fs/promises";
8
9
  //#region src/collection-watchers/config.ts
@@ -47,24 +48,60 @@ function evalNow() {
47
48
  * Used both to build new keys and to filter sweep candidates from the
48
49
  * active bell. */
49
50
  var LEGACY_ID_PREFIX = "collection-completion:";
50
- /** Stable key encoding slug + item, round-tripped through the entry's
51
+ /** Marks a key that carries a root. Placed FIRST so a rooted key can never be
52
+ * mistaken for a slug — `<prefix>@<root>\0<slug>:<itemId>` vs the original
53
+ * `<prefix><slug>:<itemId>`. NUL cannot occur in a path, a slug or an itemId,
54
+ * so the split is unambiguous. */
55
+ var ROOT_MARK = "@";
56
+ var ROOT_SEP = "\0";
57
+ /** Stable key encoding (root, slug, item), round-tripped through the entry's
51
58
  * `pluginData` so we can find it later without a side state file. Slug +
52
59
  * itemId are upstream-validated via `safeSlugName`, which forbids the
53
- * colon separator, so the two-segment parse below is unambiguous. */
54
- function completionLegacyId(slug, itemId) {
55
- return `${LEGACY_ID_PREFIX}${slug}:${itemId}`;
56
- }
57
- /** Decode a key back into its (slug, itemId) pair, or null if the string
58
- * didn't originate from this module. Used by the sweep step. */
60
+ * colon separator, so the two-segment parse below is unambiguous.
61
+ *
62
+ * COMPATIBILITY RULE — read this before changing the format. Both apps
63
+ * (MulmoClaude and MulmoTerminal) read ONE notifier file,
64
+ * `<ws>/data/notifier/active.json`; they never run simultaneously, but an id
65
+ * written by one must not produce a duplicate bell for the other. So:
66
+ *
67
+ * - with NO root, the id is byte-identical to the pre-multi-root format, and a
68
+ * single-workspace host's existing `active.json` entries keep matching;
69
+ * - a root is included ONLY when one was supplied, which is what lets two
70
+ * roots owning the same slug hold two distinct bells instead of deduping
71
+ * into each other;
72
+ * - `parseCompletionLegacyId` accepts BOTH forms, forever.
73
+ *
74
+ * The root is canonicalised here as well as at the watcher's claim, because
75
+ * this id is written to disk: a caller reaching `reconcileItem` directly with
76
+ * `/proj/` where the watcher used `/proj` would otherwise publish a SECOND
77
+ * bell for the same record, and neither pass would ever clear the other's. */
78
+ function completionLegacyId(slug, itemId, root) {
79
+ return `${LEGACY_ID_PREFIX}${root === void 0 ? "" : `${ROOT_MARK}${canonicalRoot(root)}${ROOT_SEP}`}${slug}:${itemId}`;
80
+ }
81
+ /** Decode a key back into its (root, slug, itemId), or null if the string
82
+ * didn't originate from this module. Accepts both the rooted and the
83
+ * original root-less form — see the compatibility rule above. Used by the
84
+ * sweep step. */
59
85
  function parseCompletionLegacyId(legacyId) {
60
86
  if (!legacyId.startsWith(LEGACY_ID_PREFIX)) return null;
61
- const body = legacyId.slice(22);
87
+ let body = legacyId.slice(22);
88
+ let root;
89
+ if (body.startsWith(ROOT_MARK)) {
90
+ const sep = body.indexOf(ROOT_SEP);
91
+ if (sep < 0) return null;
92
+ root = body.slice(1, sep);
93
+ body = body.slice(sep + 1);
94
+ }
62
95
  const colon = body.indexOf(":");
63
96
  if (colon < 0) return null;
64
- return {
97
+ const parsed = {
65
98
  slug: body.slice(0, colon),
66
99
  itemId: body.slice(colon + 1)
67
100
  };
101
+ return root === void 0 ? parsed : {
102
+ root,
103
+ ...parsed
104
+ };
68
105
  }
69
106
  /** The human-readable label shown in a completion notification's title.
70
107
  * Uses the schema's `displayField` value when declared and non-empty;
@@ -78,13 +115,13 @@ function resolveDisplayLabel(schema, item, itemId) {
78
115
  /** Every active bell entry whose key matches this (slug, itemId).
79
116
  * Returns multiple when defensive cleanup is needed. Scans `listAll()`
80
117
  * — cheap because the active set is bounded. */
81
- async function findActiveEntries(slug, itemId) {
118
+ async function findActiveEntries(slug, itemId, root) {
82
119
  const adapter = requireAdapter();
83
- const legacyId = completionLegacyId(slug, itemId);
120
+ const legacyId = completionLegacyId(slug, itemId, root);
84
121
  return (await listAll()).filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);
85
122
  }
86
- async function findActiveEntryIds(slug, itemId) {
87
- return (await findActiveEntries(slug, itemId)).map((entry) => entry.id);
123
+ async function findActiveEntryIds(slug, itemId, root) {
124
+ return (await findActiveEntries(slug, itemId, root)).map((entry) => entry.id);
88
125
  }
89
126
  var ensureLocks = /* @__PURE__ */ new Map();
90
127
  /** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`
@@ -97,14 +134,22 @@ function notifyPriorityForItem(schema, item) {
97
134
  const value = item[spec.field] === void 0 || item[spec.field] === null ? "" : String(item[spec.field]);
98
135
  return spec.in.indexOf(value) === 0 ? "high" : "normal";
99
136
  }
100
- async function ensureItemNotification(slug, schema, itemId, displayLabel, priority) {
101
- const legacyId = completionLegacyId(slug, itemId);
137
+ async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, root) {
138
+ const legacyId = completionLegacyId(slug, itemId, root);
102
139
  while (true) {
103
140
  const inflight = ensureLocks.get(legacyId);
104
141
  if (!inflight) break;
105
142
  await inflight.promise;
106
143
  }
107
- const lock = { promise: doEnsureItemNotification(slug, schema, itemId, legacyId, displayLabel, priority) };
144
+ const lock = { promise: doEnsureItemNotification({
145
+ slug,
146
+ schema,
147
+ itemId,
148
+ legacyId,
149
+ displayLabel,
150
+ priority,
151
+ root
152
+ }) };
108
153
  ensureLocks.set(legacyId, lock);
109
154
  try {
110
155
  await lock.promise;
@@ -116,7 +161,7 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
116
161
  * place (preserving id / position / createdAt) so a record whose flagged
117
162
  * value changed while it stayed pending re-colours the bell without a
118
163
  * clear+republish flicker. No-op when the stored priority already matches. */
119
- async function reconcileEntrySeverity(slug, itemId, entries, priority) {
164
+ async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
120
165
  const adapter = requireAdapter();
121
166
  for (const entry of entries) {
122
167
  const parsed = adapter.readEntry(entry.pluginData);
@@ -128,20 +173,21 @@ async function reconcileEntrySeverity(slug, itemId, entries, priority) {
128
173
  slug,
129
174
  itemId,
130
175
  priority,
131
- navigateTarget: adapter.buildNavigateTarget(slug, itemId)
176
+ navigateTarget: adapter.buildNavigateTarget(slug, itemId, root),
177
+ root
132
178
  })
133
179
  });
134
180
  }
135
181
  }
136
- async function doEnsureItemNotification(slug, schema, itemId, legacyId, displayLabel, priority) {
182
+ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, root }) {
137
183
  const adapter = requireAdapter();
138
184
  try {
139
- const existing = await findActiveEntries(slug, itemId);
185
+ const existing = await findActiveEntries(slug, itemId, root);
140
186
  if (existing.length > 0) {
141
- await reconcileEntrySeverity(slug, itemId, existing, priority);
187
+ await reconcileEntrySeverity(slug, itemId, existing, priority, root);
142
188
  return;
143
189
  }
144
- const navigateTarget = adapter.buildNavigateTarget(slug, itemId);
190
+ const navigateTarget = adapter.buildNavigateTarget(slug, itemId, root);
145
191
  await publish({
146
192
  pluginPkg: adapter.pluginPkg,
147
193
  severity: adapter.priorityToSeverity(priority),
@@ -153,7 +199,8 @@ async function doEnsureItemNotification(slug, schema, itemId, legacyId, displayL
153
199
  slug,
154
200
  itemId,
155
201
  priority,
156
- navigateTarget
202
+ navigateTarget,
203
+ root
157
204
  })
158
205
  });
159
206
  } catch (err) {
@@ -167,9 +214,9 @@ async function doEnsureItemNotification(slug, schema, itemId, legacyId, displayL
167
214
  /** Idempotently clear EVERY bell entry that matches this (slug, itemId).
168
215
  * Silent no-op when nothing matches. The "every" is defensive: if a
169
216
  * duplicate ever slips through, this drains the lot. */
170
- async function clearItemNotification(slug, itemId) {
217
+ async function clearItemNotification(slug, itemId, root) {
171
218
  try {
172
- const ids = await findActiveEntryIds(slug, itemId);
219
+ const ids = await findActiveEntryIds(slug, itemId, root);
173
220
  for (const entryId of ids) await clear(entryId);
174
221
  } catch (err) {
175
222
  log().warn("notify clear failed", {
@@ -189,18 +236,19 @@ async function clearItemNotification(slug, itemId) {
189
236
  * `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */
190
237
  async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
191
238
  const { slug, schema } = collection;
239
+ const root = ioOpts.workspaceRoot;
192
240
  if (!schema.completionField) {
193
- await clearItemNotification(slug, itemId);
241
+ await clearItemNotification(slug, itemId, root);
194
242
  return;
195
243
  }
196
244
  const item = await storeFor(collection, ioOpts).read(itemId);
197
245
  if (item === null) {
198
- await clearItemNotification(slug, itemId);
246
+ await clearItemNotification(slug, itemId, root);
199
247
  return;
200
248
  }
201
249
  await maybeSpawnSuccessor(collection, item, itemId, ioOpts);
202
250
  if (itemIsDone(schema, item)) {
203
- await clearItemNotification(slug, itemId);
251
+ await clearItemNotification(slug, itemId, root);
204
252
  return;
205
253
  }
206
254
  if (schema.triggerField) {
@@ -212,15 +260,15 @@ async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
212
260
  triggerField: schema.triggerField
213
261
  });
214
262
  if (due !== true) {
215
- await clearItemNotification(slug, itemId);
263
+ await clearItemNotification(slug, itemId, root);
216
264
  return;
217
265
  }
218
266
  }
219
267
  if (!whenMatches(schema.notifyWhen, item)) {
220
- await clearItemNotification(slug, itemId);
268
+ await clearItemNotification(slug, itemId, root);
221
269
  return;
222
270
  }
223
- await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item));
271
+ await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), root);
224
272
  }
225
273
  /** Boot-time reconcile: walk every record of the collection once (through
226
274
  * its store) and reconcile it. Catches up changes that happened while the
@@ -251,6 +299,36 @@ async function reconcileAllItems(collection, ioOpts = {}, now = evalNow()) {
251
299
  * file is gone, whose collection was deleted, whose schema no longer
252
300
  * tracks completion, or whose item is now done. Reverse-covers the cases
253
301
  * `reconcileAllItems` misses (it only walks files that exist). */
302
+ /** What this sweep should do with an entry, given the root it belongs to and
303
+ * the root the sweep is running for.
304
+ *
305
+ * - `mine` — same root (or both root-less: the single-workspace case, which
306
+ * behaves exactly as it always did). Judge it against the records on disk.
307
+ * - `skip` — ANOTHER root's entry. Its collection and records live in a tree
308
+ * this sweep cannot see, so every check would read "gone" and clear a
309
+ * perfectly live bell. Two roots share one notifier file; this is the
310
+ * boundary between them.
311
+ * - `drop-legacy` — a root-less entry seen by a sweep that HAS a root: a
312
+ * leftover from before bell ids carried one. Nothing this host does from now
313
+ * on produces a root-less id, so skipping it would strand a bell no pass can
314
+ * ever clear, while the reconcile publishes a second rooted one beside it.
315
+ * Clearing converges — the record republishes rooted if still pending. */
316
+ function sweepVerdict(entryRoot, ownRoot) {
317
+ if (entryRoot === ownRoot) return "mine";
318
+ if (entryRoot === void 0) return "drop-legacy";
319
+ return "skip";
320
+ }
321
+ /** Should this entry's bell be dropped? True when the collection is gone, no
322
+ * longer tracks completion, or the record is missing / done / outside
323
+ * `notifyWhen`. The reverse of the reconcile invariant, applied to an entry
324
+ * whose record the forward pass can never walk to (a delete leaves a bell that
325
+ * a walk over the SURVIVING records cannot clear). */
326
+ async function isStaleEntry(slug, itemId, opts) {
327
+ const collection = await loadCollection(slug, opts);
328
+ if (!collection || !collection.schema.completionField) return true;
329
+ const item = await storeFor(collection, opts).read(itemId);
330
+ return item === null || itemIsDone(collection.schema, item) || !whenMatches(collection.schema.notifyWhen, item);
331
+ }
254
332
  async function sweepStaleActiveEntries(opts = {}) {
255
333
  const adapter = requireAdapter();
256
334
  let entries;
@@ -260,20 +338,21 @@ async function sweepStaleActiveEntries(opts = {}) {
260
338
  log().warn("sweep list failed", { error: errMsg(err) });
261
339
  return;
262
340
  }
341
+ const ownRoot = opts.workspaceRoot === void 0 ? void 0 : canonicalRoot(opts.workspaceRoot);
263
342
  for (const entry of entries) {
264
343
  const own = adapter.readEntry(entry.pluginData);
265
344
  if (!own) continue;
266
345
  const parsed = parseCompletionLegacyId(own.legacyId);
267
346
  if (!parsed) continue;
347
+ const verdict = sweepVerdict(parsed.root, ownRoot);
348
+ if (verdict === "skip") continue;
349
+ if (verdict === "drop-legacy") {
350
+ await clear(entry.id);
351
+ continue;
352
+ }
268
353
  const { slug, itemId } = parsed;
269
354
  try {
270
- const collection = await loadCollection(slug, opts);
271
- if (!collection || !collection.schema.completionField) {
272
- await clear(entry.id);
273
- continue;
274
- }
275
- const item = await storeFor(collection, opts).read(itemId);
276
- if (item === null || itemIsDone(collection.schema, item) || !whenMatches(collection.schema.notifyWhen, item)) await clear(entry.id);
355
+ if (await isStaleEntry(slug, itemId, opts)) await clear(entry.id);
277
356
  } catch (err) {
278
357
  log().warn("sweep entry failed", {
279
358
  slug,
@@ -293,173 +372,205 @@ var ONE_SECOND_MS = 1e3;
293
372
  var ONE_MINUTE_MS = 60 * ONE_SECOND_MS;
294
373
  var REDISCOVERY_INTERVAL_MS = 30 * ONE_SECOND_MS;
295
374
  var TRIGGER_TICK_INTERVAL_MS = ONE_MINUTE_MS;
296
- var watchers = /* @__PURE__ */ new Map();
297
- var rediscoveryTimer = null;
298
- var triggerTimer = null;
299
- var started = false;
300
- /** Guards the clock tick against overlapping itself. Paired with
301
- * `watcherEpoch`: a teardown while a pass is in flight bumps the epoch, so
302
- * that pass's `finally` knows it belongs to a dead generation and must not
303
- * clear a guard the restarted watcher set now owns. `triggerTickInFlight`
304
- * is what teardown AWAITS — clearing the flag alone would let a restart run
305
- * a second pass alongside the first. */
306
- var triggerTickRunning = false;
307
- var watcherEpoch = 0;
308
- var triggerTickInFlight = null;
309
- /** The boot pass, from before its first await until it settles. This is the
310
- * generation CLAIM: `started` flips only at the end of boot, so it cannot by
311
- * itself stop two concurrent starts from racing each other's state. Teardown
312
- * awaits this too, so a boot in flight can't arm its intervals after the stop
313
- * that was meant to disarm them. */
314
- var bootInFlight = null;
315
- /** The root the running (or booting) generation belongs to, NORMALIZED: an
316
- * omitted `workspaceRoot` resolves to the host's configured default, so
317
- * `start()` and `start({ workspaceRoot: <that same default> })` are the same
318
- * generation rather than a spurious conflict. Distinct from
319
- * `discoveryOpts.workspaceRoot`, which stays un-normalized because the change
320
- * payload's `root` means "an explicit root was passed", not "this root". */
321
- var activeRoot;
322
- /** `err.code` on the conflict throw below: another ROOT's watcher generation is
323
- * already up. Distinct from `COLLECTION_ROOT_REQUIRED` (this call forgot its
324
- * `workspaceRoot`) because the fixes differ — stop the running generation
325
- * versus pass the option — and a host that catches watcher startup in one
326
- * fire-and-forget `.catch` should not have to match on message text. */
375
+ /** Live generations, keyed by NORMALIZED root: an omitted `workspaceRoot`
376
+ * resolves to the host's configured default, so `start()` and
377
+ * `start({ workspaceRoot: <that same default> })` join one generation rather
378
+ * than mounting two watchers over the same tree. */
379
+ var generations = /* @__PURE__ */ new Map();
380
+ /** Map key for a normalized root. `""` stands for "the host has no configured
381
+ * default and this call named no root" — a state in which discovery itself
382
+ * would throw, so at most one such generation can meaningfully exist. */
383
+ var generationKey = (root) => root ?? "";
384
+ /** Retained for hosts that catch it. NO LONGER THROWN: starting a second root
385
+ * now mounts a second generation instead of refusing. It stays exported
386
+ * because the refusal was a documented, catchable failure and a host's
387
+ * `.catch` branch for it must keep compiling — and because a future host-side
388
+ * constraint that reintroduces a conflict should reuse this code rather than
389
+ * invent a second one. */
327
390
  var WATCHER_ROOT_CONFLICT = "WATCHER_ROOT_CONFLICT";
328
391
  /** The root a start request resolves to: its explicit override, else the host's
329
392
  * configured default. `peekWorkspaceRoot` rather than `getWorkspaceRoot` — this
330
393
  * is a comparison, and under an explicit-root binding there is no default to
331
394
  * read, which is not an error here. */
332
395
  function effectiveRoot(opts) {
333
- return opts?.workspaceRoot ?? peekWorkspaceRoot() ?? void 0;
334
- }
335
- /** Discovery options threaded into every `discoverCollections` /
336
- * `loadCollection` / `sweepStaleActiveEntries` call. Production: empty
337
- * (live workspace). Tests: `{ workspaceRoot, userSkillsDir }` pointing
338
- * at a fixture tree. Module-level so per-event handlers can read it
339
- * without threading through every signature. */
340
- var discoveryOpts = {};
341
- var itemSlots = /* @__PURE__ */ new Map();
342
- /** Per-slug single-flight for a COLLECTION-granularity reconcile — a burst
343
- * of changes the store couldn't attribute to a record collapses into one
344
- * pass plus one trailing re-run, mirroring the per-item slots. */
345
- var collectionSlots = /* @__PURE__ */ new Map();
396
+ const root = opts?.workspaceRoot ?? peekWorkspaceRoot() ?? void 0;
397
+ return root === void 0 ? void 0 : canonicalRoot(root);
398
+ }
399
+ /** The discovery options a generation runs under, with an explicit root put in
400
+ * canonical form. Everything downstream — the change payload's `root`, the
401
+ * bell id the reconciler derives — reads it from here, so canonicalising once
402
+ * at the claim is what keeps those three consistent. An omitted root stays
403
+ * omitted: "no explicit root" is a distinct state, not a root to normalise. */
404
+ function canonicalDiscoveryOpts(opts) {
405
+ const base = opts ?? {};
406
+ return base.workspaceRoot === void 0 ? base : {
407
+ ...base,
408
+ workspaceRoot: canonicalRoot(base.workspaceRoot)
409
+ };
410
+ }
411
+ function newGeneration(discoveryOpts) {
412
+ return {
413
+ discoveryOpts,
414
+ watchers: /* @__PURE__ */ new Map(),
415
+ itemSlots: /* @__PURE__ */ new Map(),
416
+ collectionSlots: /* @__PURE__ */ new Map(),
417
+ rediscoveryTimer: null,
418
+ triggerTimer: null,
419
+ started: false,
420
+ triggerTickRunning: false,
421
+ triggerTickInFlight: null,
422
+ alive: true,
423
+ bootInFlight: null
424
+ };
425
+ }
426
+ /** The generation used when NO watcher is running at all. Several tests (and a
427
+ * host that drives a reconcile directly) call the reconcile helpers without a
428
+ * boot; they still need ONE stable set of single-flight slots, or a burst of
429
+ * calls that should coalesce into a single publish would each get their own
430
+ * slot. Created lazily and dropped by a full `stopCollectionWatchers()`. */
431
+ var detachedGeneration = null;
432
+ function fallbackGeneration() {
433
+ detachedGeneration ??= newGeneration({});
434
+ return detachedGeneration;
435
+ }
436
+ /** The generation a test helper or a root-less call should act on: the one
437
+ * named by `workspaceRoot`, else the only one running, else the default
438
+ * root's. "The only one running" is what keeps the single-root test helpers
439
+ * (which name no root) working against a generation booted for a tmpdir. */
440
+ function resolveGeneration(workspaceRoot) {
441
+ if (workspaceRoot !== void 0) return generations.get(generationKey(workspaceRoot));
442
+ if (generations.size === 1) return [...generations.values()][0];
443
+ return generations.get(generationKey(effectiveRoot(void 0)));
444
+ }
346
445
  /** Boot entry point: sweep stale active entries, then mount watchers for
347
446
  * every discovered collection and arm the periodic re-discovery poll.
348
- * Idempotent for the SAME root — a second call is a no-op.
447
+ * Idempotent for the SAME root — a second call joins the first.
349
448
  *
350
- * A second call for a DIFFERENT root throws. This module holds exactly one
351
- * watcher generation per process (see the "single generation" note in the file
352
- * header), so silently returning would leave the second root unwatched: direct
353
- * file writes there — the canonical agent path — would emit neither
354
- * live-refresh events nor completion bells, with nothing anywhere saying so.
355
- * That is the same class of silent-wrong-root failure the explicit-root host
356
- * binding exists to make loud, so it is loud here too. */
449
+ * A call for a DIFFERENT root mounts a SECOND generation beside the first, so
450
+ * a multi-root host can watch several projects at once. Each generation keeps
451
+ * its own watcher set, timers and single-flight slots, stamps its own root on
452
+ * its change payloads, and drives bells whose ids carry that root — which is
453
+ * what stops two projects' `tasks` collections from deduping into one bell. */
357
454
  async function startCollectionWatchers(opts = {}) {
358
- const requested = effectiveRoot(opts.discoveryOpts);
359
- if (started || bootInFlight !== null) {
360
- if (requested !== activeRoot) throw Object.assign(/* @__PURE__ */ new Error(`@mulmoclaude/core/collection-watchers: watchers are already ${started ? "running" : "starting"} for root ${String(activeRoot)}; this process holds one watcher generation, so starting another for ${String(requested)} would leave it unwatched. Call stopCollectionWatchers() first, or run one process per root.`), { code: WATCHER_ROOT_CONFLICT });
361
- await bootInFlight;
455
+ const key = generationKey(effectiveRoot(opts.discoveryOpts));
456
+ const existing = generations.get(key);
457
+ if (existing) {
458
+ await existing.bootInFlight;
362
459
  return;
363
460
  }
364
- discoveryOpts = opts.discoveryOpts ?? {};
365
- activeRoot = requested;
366
- bootInFlight = bootWatchers(opts);
461
+ const gen = newGeneration(canonicalDiscoveryOpts(opts.discoveryOpts));
462
+ generations.set(key, gen);
463
+ gen.bootInFlight = bootWatchers(key, gen, opts);
367
464
  try {
368
- await bootInFlight;
465
+ await gen.bootInFlight;
369
466
  } finally {
370
- bootInFlight = null;
467
+ gen.bootInFlight = null;
371
468
  }
372
469
  }
373
470
  /** The boot pass itself, owned by exactly one caller — `startCollectionWatchers`
374
471
  * serialises entry into it. On failure it resets `discoveryOpts` so a
375
472
  * supervisor / test harness can retry instead of being permanently latched. */
376
- async function bootWatchers(opts) {
473
+ async function bootWatchers(key, gen, opts) {
377
474
  try {
378
- await sweepStaleActiveEntries(discoveryOpts);
379
- await syncWatchers();
475
+ await sweepStaleActiveEntries(gen.discoveryOpts);
476
+ await syncWatchers(gen);
380
477
  const intervalMs = opts.rediscoveryIntervalMs === void 0 ? REDISCOVERY_INTERVAL_MS : opts.rediscoveryIntervalMs;
381
478
  if (intervalMs !== null) {
382
- rediscoveryTimer = setInterval(() => {
383
- syncWatchers().catch((err) => {
479
+ gen.rediscoveryTimer = setInterval(() => {
480
+ syncWatchers(gen).catch((err) => {
384
481
  log().warn("watcher rediscovery failed", { error: errMsg(err) });
385
482
  });
386
483
  }, intervalMs);
387
- rediscoveryTimer.unref();
484
+ gen.rediscoveryTimer.unref();
388
485
  }
389
486
  const triggerMs = opts.triggerTickIntervalMs === void 0 ? TRIGGER_TICK_INTERVAL_MS : opts.triggerTickIntervalMs;
390
487
  if (triggerMs !== null) {
391
- triggerTimer = setInterval(() => {
392
- if (triggerTickRunning) return;
393
- triggerTickRunning = true;
394
- const epoch = watcherEpoch;
395
- triggerTickInFlight = tickTimeTriggers().catch((err) => {
488
+ gen.triggerTimer = setInterval(() => {
489
+ if (gen.triggerTickRunning) return;
490
+ gen.triggerTickRunning = true;
491
+ gen.triggerTickInFlight = tickTimeTriggers(gen).catch((err) => {
396
492
  log().warn("watcher trigger tick failed", { error: errMsg(err) });
397
493
  }).finally(() => {
398
- if (epoch !== watcherEpoch) return;
399
- triggerTickRunning = false;
400
- triggerTickInFlight = null;
494
+ if (!gen.alive) return;
495
+ gen.triggerTickRunning = false;
496
+ gen.triggerTickInFlight = null;
401
497
  });
402
498
  }, triggerMs);
403
- triggerTimer.unref();
499
+ gen.triggerTimer.unref();
404
500
  }
405
- started = true;
501
+ gen.started = true;
406
502
  } catch (err) {
407
- discoveryOpts = {};
408
- activeRoot = void 0;
503
+ gen.alive = false;
504
+ generations.delete(key);
409
505
  throw err;
410
506
  }
411
507
  }
412
- /** Tear down every watcher and stop the intervals, and release the root claim
413
- * so a subsequent `startCollectionWatchers` may re-mount — for ANY root.
508
+ /** Tear down watchers and stop their intervals, releasing the root claim so a
509
+ * subsequent `startCollectionWatchers` may re-mount.
510
+ *
511
+ * SCOPE: `{ workspaceRoot }` stops exactly that root's generation and leaves
512
+ * every other project running — what a multi-root host wants when it closes
513
+ * one project. Called with NO options it stops ALL generations, which is what
514
+ * it has always meant for a single-workspace host (and for a test teardown
515
+ * that must not leak a timer into the next file).
414
516
  *
415
- * This is a production API, not a test-only one. A single-workspace host never
416
- * calls it (process exit reclaims the fds), but since starting a second root
417
- * now throws, `stop()` → `start(otherRoot)` is the ONLY way a multi-root host
418
- * switches projects. Await it: it waits out a boot still in flight and a clock
419
- * pass still running, so the next start does not race the one it replaced. */
420
- async function stopCollectionWatchers() {
421
- await bootInFlight?.catch(() => {});
422
- if (rediscoveryTimer) {
423
- clearInterval(rediscoveryTimer);
424
- rediscoveryTimer = null;
517
+ * This is a production API, not a test-only one. Await it: it waits out a boot
518
+ * still in flight and a clock pass still running, so the next start does not
519
+ * race the one it replaced. */
520
+ async function stopCollectionWatchers(opts) {
521
+ if (opts?.workspaceRoot !== void 0) {
522
+ const key = generationKey(effectiveRoot(opts));
523
+ const gen = generations.get(key);
524
+ if (gen) await stopGeneration(key, gen);
525
+ return;
526
+ }
527
+ for (const [key, gen] of [...generations.entries()]) await stopGeneration(key, gen);
528
+ if (opts?.workspaceRoot === void 0) detachedGeneration = null;
529
+ }
530
+ async function stopGeneration(key, gen) {
531
+ await gen.bootInFlight?.catch(() => {});
532
+ if (gen.rediscoveryTimer) {
533
+ clearInterval(gen.rediscoveryTimer);
534
+ gen.rediscoveryTimer = null;
425
535
  }
426
- if (triggerTimer) {
427
- clearInterval(triggerTimer);
428
- triggerTimer = null;
536
+ if (gen.triggerTimer) {
537
+ clearInterval(gen.triggerTimer);
538
+ gen.triggerTimer = null;
429
539
  }
430
- await triggerTickInFlight;
431
- triggerTickInFlight = null;
432
- watcherEpoch += 1;
433
- triggerTickRunning = false;
434
- for (const watcher of watchers.values()) try {
540
+ await gen.triggerTickInFlight;
541
+ gen.triggerTickInFlight = null;
542
+ gen.alive = false;
543
+ gen.triggerTickRunning = false;
544
+ for (const watcher of gen.watchers.values()) try {
435
545
  watcher.unsubscribe();
436
546
  } catch {}
437
- watchers.clear();
438
- itemSlots.clear();
439
- collectionSlots.clear();
440
- discoveryOpts = {};
441
- activeRoot = void 0;
442
- started = false;
547
+ gen.watchers.clear();
548
+ gen.itemSlots.clear();
549
+ gen.collectionSlots.clear();
550
+ gen.started = false;
551
+ generations.delete(key);
443
552
  }
444
553
  /** Test-only: manually trigger one rediscovery + reconcile pass. */
445
- async function _syncWatchersForTesting() {
446
- return syncWatchers();
554
+ async function _syncWatchersForTesting(workspaceRoot) {
555
+ const gen = resolveGeneration(workspaceRoot);
556
+ return gen ? syncWatchers(gen) : false;
447
557
  }
448
558
  /** Test-only: drive one wall-clock tick synchronously, with an optional
449
559
  * injected clock. */
450
- async function _tickTimeTriggersForTesting(now) {
451
- await tickTimeTriggers(now);
560
+ async function _tickTimeTriggersForTesting(now, workspaceRoot) {
561
+ const gen = resolveGeneration(workspaceRoot);
562
+ if (gen) await tickTimeTriggers(gen, now);
452
563
  }
453
564
  /** Re-reconcile every watched collection that depends on the clock — i.e.
454
565
  * declares `triggerField` (a bell that fires at a date) and/or `spawn`
455
566
  * (recurrence whose successors come due over time). Collections with
456
567
  * neither are skipped. Idempotent. Reads the watcher's cached
457
568
  * `collection.schema` to avoid a per-tick disk read. */
458
- async function tickTimeTriggers(now = evalNow()) {
459
- for (const entry of watchers.values()) {
569
+ async function tickTimeTriggers(gen, now = evalNow()) {
570
+ for (const entry of gen.watchers.values()) {
460
571
  const { schema } = entry.collection;
461
572
  if (!schema.triggerField && !schema.spawn) continue;
462
- await reconcileAllItems(entry.collection, discoveryOpts, now);
573
+ await reconcileAllItems(entry.collection, gen.discoveryOpts, now);
463
574
  }
464
575
  }
465
576
  /** Reconcile the watcher set against the currently-discovered
@@ -467,30 +578,30 @@ async function tickTimeTriggers(now = evalNow()) {
467
578
  * their items), drops watchers for vanished slugs, and re-reconciles
468
579
  * items for collections whose schema changed. Runs a final sweep when
469
580
  * this tick changed the watcher set or any schema. */
470
- async function syncWatchers() {
581
+ async function syncWatchers(gen) {
471
582
  let collections;
472
583
  try {
473
- collections = await discoverCollections(discoveryOpts);
584
+ collections = await discoverCollections(gen.discoveryOpts);
474
585
  } catch (err) {
475
586
  log().warn("watcher discover failed", { error: errMsg(err) });
476
587
  return false;
477
588
  }
478
- const vanishedMutated = stopVanishedWatchers(new Set(collections.map((collection) => collection.slug)));
479
- const schemaMutated = await reconcileChangedSchemas(collections);
480
- const addedMutated = await startNewWatchers(collections);
589
+ const vanishedMutated = stopVanishedWatchers(gen, new Set(collections.map((collection) => collection.slug)));
590
+ const schemaMutated = await reconcileChangedSchemas(gen, collections);
591
+ const addedMutated = await startNewWatchers(gen, collections);
481
592
  if (!vanishedMutated && !schemaMutated && !addedMutated) return false;
482
- await sweepStaleActiveEntries(discoveryOpts);
593
+ await sweepStaleActiveEntries(gen.discoveryOpts);
483
594
  return true;
484
595
  }
485
- function stopVanishedWatchers(liveSlugs) {
596
+ function stopVanishedWatchers(gen, liveSlugs) {
486
597
  let mutated = false;
487
- for (const slug of [...watchers.keys()]) {
598
+ for (const slug of [...gen.watchers.keys()]) {
488
599
  if (liveSlugs.has(slug)) continue;
489
- const watcher = watchers.get(slug);
600
+ const watcher = gen.watchers.get(slug);
490
601
  if (watcher) try {
491
602
  watcher.unsubscribe();
492
603
  } catch {}
493
- watchers.delete(slug);
604
+ gen.watchers.delete(slug);
494
605
  mutated = true;
495
606
  log().info("watcher stopped", { slug });
496
607
  }
@@ -505,10 +616,10 @@ function storagePathChanged(previous, next) {
505
616
  }
506
617
  /** Re-reconcile already-watched collections whose schema changed since
507
618
  * the last tick. New collections fall through to `startNewWatchers`. */
508
- async function reconcileChangedSchemas(collections) {
619
+ async function reconcileChangedSchemas(gen, collections) {
509
620
  let mutated = false;
510
621
  for (const collection of collections) {
511
- const existing = watchers.get(collection.slug);
622
+ const existing = gen.watchers.get(collection.slug);
512
623
  if (!existing) continue;
513
624
  const nextJson = JSON.stringify(collection.schema);
514
625
  if (existing.schemaJson === nextJson) continue;
@@ -517,8 +628,8 @@ async function reconcileChangedSchemas(collections) {
517
628
  try {
518
629
  existing.unsubscribe();
519
630
  } catch {}
520
- watchers.delete(collection.slug);
521
- if (collection.schema.dataSource !== void 0) safePublish({
631
+ gen.watchers.delete(collection.slug);
632
+ if (collection.schema.dataSource !== void 0) safePublish(gen, {
522
633
  slug: collection.slug,
523
634
  op: "upsert"
524
635
  });
@@ -528,8 +639,8 @@ async function reconcileChangedSchemas(collections) {
528
639
  existing.schemaJson = nextJson;
529
640
  existing.collection = collection;
530
641
  log().info("watcher schema changed, re-reconciling", { slug: collection.slug });
531
- await reconcileAllItems(collection, discoveryOpts);
532
- if (collection.schema.dataSource !== void 0) safePublish({
642
+ await reconcileAllItems(collection, gen.discoveryOpts);
643
+ if (collection.schema.dataSource !== void 0) safePublish(gen, {
533
644
  slug: collection.slug,
534
645
  op: "upsert"
535
646
  });
@@ -543,11 +654,11 @@ async function reconcileChangedSchemas(collections) {
543
654
  * (logged and swallowed inside the starter) leaves `watchers` untouched, so
544
655
  * the collection is retried next tick; counting that as a mutation made
545
656
  * `syncWatchers` sweep on every tick for as long as the failure persisted. */
546
- async function startNewWatchers(collections) {
657
+ async function startNewWatchers(gen, collections) {
547
658
  let mutated = false;
548
659
  for (const collection of collections) {
549
- if (watchers.has(collection.slug)) continue;
550
- if (await startWatcherFor(collection)) mutated = true;
660
+ if (gen.watchers.has(collection.slug)) continue;
661
+ if (await startWatcherFor(gen, collection)) mutated = true;
551
662
  }
552
663
  return mutated;
553
664
  }
@@ -560,13 +671,13 @@ async function startNewWatchers(collections) {
560
671
  * A store without `watch` cannot report external changes at all — it is
561
672
  * still registered (so bells reconcile at boot and on the clock tick), just
562
673
  * without live updates. */
563
- async function startWatcherFor(collection) {
674
+ async function startWatcherFor(gen, collection) {
564
675
  const { slug } = collection;
565
676
  try {
566
- await reconcileAllItems(collection, discoveryOpts);
567
- const store = storeFor(collection, discoveryOpts);
677
+ await reconcileAllItems(collection, gen.discoveryOpts);
678
+ const store = storeFor(collection, gen.discoveryOpts);
568
679
  const unsubscribe = store.watch ? await store.watch((change) => {
569
- handleStoreChange(slug, change).catch((err) => {
680
+ handleStoreChange(gen, slug, change).catch((err) => {
570
681
  log().warn("store change handling failed", {
571
682
  slug,
572
683
  error: errMsg(err)
@@ -577,7 +688,7 @@ async function startWatcherFor(collection) {
577
688
  log().warn("collection watcher could not arm, retrying next sync", { slug });
578
689
  return false;
579
690
  }
580
- watchers.set(slug, {
691
+ gen.watchers.set(slug, {
581
692
  slug,
582
693
  dataDir: collection.dataDir,
583
694
  unsubscribe,
@@ -604,26 +715,26 @@ async function startWatcherFor(collection) {
604
715
  * store couldn't say which record, so re-derive everything and pair it with
605
716
  * a sweep — a record deleted remotely leaves a bell that a walk over the
606
717
  * SURVIVING records can never clear. */
607
- async function handleStoreChange(slug, change) {
718
+ async function handleStoreChange(gen, slug, change) {
608
719
  if (change.kind === "collection") {
609
- await scheduleCollectionReconcile(slug);
720
+ await scheduleCollectionReconcile(gen, slug);
610
721
  return;
611
722
  }
612
- const current = watchers.get(slug)?.collection;
723
+ const current = gen.watchers.get(slug)?.collection;
613
724
  if (!current) return;
614
- await scheduleItemReconcile(current, change.itemId);
725
+ await scheduleItemReconcile(gen, current, change.itemId);
615
726
  }
616
727
  /** Full re-derivation for a collection-granularity change, single-flighted
617
728
  * per slug so a burst of writes collapses into one pass plus one trailing
618
729
  * re-run. */
619
- function scheduleCollectionReconcile(slug) {
620
- return runSingleFlight(collectionSlots, slug, async () => {
621
- const collection = await loadCollection(slug, discoveryOpts);
730
+ function scheduleCollectionReconcile(gen, slug) {
731
+ return runSingleFlight(gen.collectionSlots, slug, async () => {
732
+ const collection = await loadCollection(slug, gen.discoveryOpts);
622
733
  if (!collection) return;
623
- await reconcileAllItems(collection, discoveryOpts);
624
- await sweepStaleActiveEntries(discoveryOpts);
734
+ await reconcileAllItems(collection, gen.discoveryOpts);
735
+ await sweepStaleActiveEntries(gen.discoveryOpts);
625
736
  }, () => {
626
- safePublish({
737
+ safePublish(gen, {
627
738
  slug,
628
739
  op: "upsert"
629
740
  });
@@ -633,18 +744,19 @@ function scheduleCollectionReconcile(slug) {
633
744
  /** Test-only: feed one store-reported change through the same path a live
634
745
  * subscription uses, so a test can pin how a change is reacted to without
635
746
  * depending on fs.watch timing. */
636
- function _handleStoreChangeForTesting(slug, change) {
637
- return handleStoreChange(slug, change);
747
+ function _handleStoreChangeForTesting(slug, change, workspaceRoot) {
748
+ const gen = resolveGeneration(workspaceRoot);
749
+ return gen ? handleStoreChange(gen, slug, change) : Promise.resolve();
638
750
  }
639
751
  /** Test-only: drive one collection-granularity reconcile directly. */
640
- function _scheduleCollectionReconcileForTesting(slug) {
641
- return scheduleCollectionReconcile(slug);
752
+ function _scheduleCollectionReconcileForTesting(slug, workspaceRoot) {
753
+ return scheduleCollectionReconcile(resolveGeneration(workspaceRoot) ?? fallbackGeneration(), slug);
642
754
  }
643
- function _scheduleItemReconcileForTesting(collection, itemId) {
644
- return scheduleItemReconcile(collection, itemId);
755
+ function _scheduleItemReconcileForTesting(collection, itemId, workspaceRoot) {
756
+ return scheduleItemReconcile(resolveGeneration(workspaceRoot) ?? fallbackGeneration(), collection, itemId);
645
757
  }
646
- function scheduleItemReconcile(collection, itemId) {
647
- return runSingleFlight(itemSlots, `${collection.slug}\x00${itemId}`, () => reconcileItem(collection, itemId, discoveryOpts), () => publishItemChange(collection, itemId));
758
+ function scheduleItemReconcile(gen, collection, itemId) {
759
+ return runSingleFlight(gen.itemSlots, `${collection.slug}\x00${itemId}`, () => reconcileItem(collection, itemId, gen.discoveryOpts), () => publishItemChange(gen, collection, itemId));
648
760
  }
649
761
  /** The shared single-flight loop behind both schedulers. Re-runs `pass`
650
762
  * while events keep arriving — the trailing re-run captures any state
@@ -685,9 +797,9 @@ function runSingleFlight(slots, key, pass, onSettled) {
685
797
  * of change nor, reliably, which of `rename`/`change` means what. Only
686
798
  * file-backed collections reach here; a `storage` (db) collection has no
687
799
  * per-record file and publishes wholesale in `scheduleStorageReconcile`. */
688
- async function publishItemChange(collection, itemId) {
800
+ async function publishItemChange(gen, collection, itemId) {
689
801
  const changeOp = await itemFileExists(collection.dataDir, itemId) ? "upsert" : "delete";
690
- safePublish({
802
+ safePublish(gen, {
691
803
  slug: collection.slug,
692
804
  ids: [itemId],
693
805
  op: changeOp
@@ -709,8 +821,8 @@ async function itemFileExists(dataDir, itemId) {
709
821
  * carries only absolute paths, and a root reconstructed by string surgery
710
822
  * would be a guess. Undefined in a single-workspace host, which is exactly
711
823
  * what the payload contract means by "the host's configured root". */
712
- function safePublish(payload) {
713
- const enriched = collectionChangePayload(payload, discoveryOpts.workspaceRoot);
824
+ function safePublish(gen, payload) {
825
+ const enriched = collectionChangePayload(payload, gen.discoveryOpts.workspaceRoot);
714
826
  try {
715
827
  publishCollectionChange(enriched);
716
828
  } catch (err) {