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