@mulmoclaude/core 3.5.0 → 3.7.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 (56) hide show
  1. package/assets/helps/collection-skills.md +49 -1
  2. package/assets/helps/error-recovery.md +53 -0
  3. package/dist/calendarGrid-CQ8MVSRb.js.map +1 -1
  4. package/dist/calendarGrid-DGILaVxI.cjs.map +1 -1
  5. package/dist/collection/core/schema.d.ts +8 -1
  6. package/dist/collection/core/schemaZ.d.ts +36 -26
  7. package/dist/collection/firestore.cjs +51 -0
  8. package/dist/collection/firestore.cjs.map +1 -0
  9. package/dist/collection/firestore.d.ts +1 -0
  10. package/dist/collection/firestore.js +50 -0
  11. package/dist/collection/firestore.js.map +1 -0
  12. package/dist/collection/registry/server/index.cjs +19 -19
  13. package/dist/collection/registry/server/index.cjs.map +1 -1
  14. package/dist/collection/registry/server/index.js +2 -2
  15. package/dist/collection/server/appManifest.d.ts +53 -0
  16. package/dist/collection/server/delete.d.ts +10 -0
  17. package/dist/collection/server/discoveredCollection.d.ts +10 -0
  18. package/dist/collection/server/discovery.d.ts +1 -0
  19. package/dist/collection/server/firestoreDocs.d.ts +39 -0
  20. package/dist/collection/server/firestoreStore.d.ts +14 -0
  21. package/dist/collection/server/host.d.ts +52 -0
  22. package/dist/collection/server/index.cjs +72 -52
  23. package/dist/collection/server/index.d.ts +8 -1
  24. package/dist/collection/server/index.js +3 -3
  25. package/dist/collection/server/manageTool.d.ts +4 -0
  26. package/dist/collection/server/publish.d.ts +56 -0
  27. package/dist/collection/server/publishChecks.d.ts +29 -0
  28. package/dist/collection/server/publishManifest.d.ts +181 -0
  29. package/dist/collection/server/publishProject.d.ts +86 -0
  30. package/dist/collection/server/validate.d.ts +12 -0
  31. package/dist/collection-watchers/index.cjs +143 -52
  32. package/dist/collection-watchers/index.cjs.map +1 -1
  33. package/dist/collection-watchers/index.js +132 -41
  34. package/dist/collection-watchers/index.js.map +1 -1
  35. package/dist/collection-watchers/reconciler.d.ts +1 -1
  36. package/dist/feeds/server/index.cjs +10 -10
  37. package/dist/feeds/server/index.cjs.map +1 -1
  38. package/dist/feeds/server/index.js +2 -2
  39. package/dist/google/index.cjs +12 -12
  40. package/dist/google/index.cjs.map +1 -1
  41. package/dist/google/index.js +1 -1
  42. package/dist/{server-BiRLLMpW.js → server-B48Jyxcj.js} +1100 -230
  43. package/dist/server-B48Jyxcj.js.map +1 -0
  44. package/dist/{server-5EMj3naj.cjs → server-CWZyg8fn.cjs} +1333 -385
  45. package/dist/server-CWZyg8fn.cjs.map +1 -0
  46. package/dist/{discovery-Ck4AqikY.cjs → store-5_P_NsGa.cjs} +2108 -1755
  47. package/dist/store-5_P_NsGa.cjs.map +1 -0
  48. package/dist/{discovery-DH9wweuj.js → store-_61sO8K8.js} +2333 -2022
  49. package/dist/store-_61sO8K8.js.map +1 -0
  50. package/dist/whisper/index.cjs +1 -1
  51. package/dist/whisper/index.js +1 -1
  52. package/package.json +7 -1
  53. package/dist/discovery-Ck4AqikY.cjs.map +0 -1
  54. package/dist/discovery-DH9wweuj.js.map +0 -1
  55. package/dist/server-5EMj3naj.cjs.map +0 -1
  56. package/dist/server-BiRLLMpW.js.map +0 -1
@@ -2,8 +2,8 @@ import { t as canonicalRoot } from "../root-BMroU_mB.js";
2
2
  import { R as fieldText, y as isValidCollectionName } from "../calendarGrid-CQ8MVSRb.js";
3
3
  import { E as whenMatches, f as itemIsDone } from "../promptSafety-CpiME8pj.js";
4
4
  import "../collection/index.js";
5
- import { d as storeFor, et as peekWorkspaceRoot, n as discoverCollections, nt as publishCollectionChange, q as collectionChangePayload, r as loadCollection, z as itemFilePath } from "../discovery-DH9wweuj.js";
6
- import { d as isTriggerDue, f as maybeSpawnSuccessor } from "../server-BiRLLMpW.js";
5
+ import { D as loadCollection, E as discoverCollections, H as itemFilePath, Q as collectionChangePayload, ct as publishCollectionChange, ot as peekWorkspaceRoot, r as storeFor, tt as firestoreHandle } from "../store-_61sO8K8.js";
6
+ import { d as isTriggerDue, f as maybeSpawnSuccessor } from "../server-B48Jyxcj.js";
7
7
  import { d as publish, m as updateForPlugin, n as clear, s as listAll } from "../notifier-DhnwR82U.js";
8
8
  import { access } from "node:fs/promises";
9
9
  //#region src/collection-watchers/config.ts
@@ -132,16 +132,28 @@ function resolveDisplayLabel(schema, item, itemId) {
132
132
  const label = fieldText(item[displayField]).trim();
133
133
  return label.length > 0 ? label : itemId;
134
134
  }
135
+ /** The scope a collection's bells belong to.
136
+ *
137
+ * A shared collection's `appId` wins over the call's `workspaceRoot`: the
138
+ * root is where this host happens to have the repository checked out, which
139
+ * is exactly the fact that must NOT reach the identity. A local collection is
140
+ * unchanged — its root is present only when the call carried an explicit one,
141
+ * so a single-workspace host's ids stay byte-identical to what it has already
142
+ * written to `active.json`. */
143
+ function bellScopeOf(collection, ioOpts) {
144
+ return collection.appId === void 0 ? { root: ioOpts.workspaceRoot } : { aid: collection.appId };
145
+ }
146
+ var scopedLegacyId = (slug, itemId, scope) => completionLegacyId(slug, itemId, scope.root, scope.aid);
135
147
  /** Every active bell entry whose key matches this (slug, itemId).
136
148
  * Returns multiple when defensive cleanup is needed. Scans `listAll()`
137
149
  * — cheap because the active set is bounded. */
138
- async function findActiveEntries(slug, itemId, root) {
150
+ async function findActiveEntries(slug, itemId, scope) {
139
151
  const adapter = requireAdapter();
140
- const legacyId = completionLegacyId(slug, itemId, root);
152
+ const legacyId = scopedLegacyId(slug, itemId, scope);
141
153
  return (await listAll()).filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);
142
154
  }
143
- async function findActiveEntryIds(slug, itemId, root) {
144
- return (await findActiveEntries(slug, itemId, root)).map((entry) => entry.id);
155
+ async function findActiveEntryIds(slug, itemId, scope) {
156
+ return (await findActiveEntries(slug, itemId, scope)).map((entry) => entry.id);
145
157
  }
146
158
  var ensureLocks = /* @__PURE__ */ new Map();
147
159
  /** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`
@@ -154,8 +166,8 @@ function notifyPriorityForItem(schema, item) {
154
166
  const value = item[spec.field] === void 0 || item[spec.field] === null ? "" : String(item[spec.field]);
155
167
  return spec.in.indexOf(value) === 0 ? "high" : "normal";
156
168
  }
157
- async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, root) {
158
- const legacyId = completionLegacyId(slug, itemId, root);
169
+ async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, scope) {
170
+ const legacyId = scopedLegacyId(slug, itemId, scope);
159
171
  while (true) {
160
172
  const inflight = ensureLocks.get(legacyId);
161
173
  if (!inflight) break;
@@ -168,7 +180,7 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
168
180
  legacyId,
169
181
  displayLabel,
170
182
  priority,
171
- root
183
+ scope
172
184
  }) };
173
185
  ensureLocks.set(legacyId, lock);
174
186
  try {
@@ -181,8 +193,9 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
181
193
  * place (preserving id / position / createdAt) so a record whose flagged
182
194
  * value changed while it stayed pending re-colours the bell without a
183
195
  * clear+republish flicker. No-op when the stored priority already matches. */
184
- async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
196
+ async function reconcileEntrySeverity(slug, itemId, entries, priority, scope) {
185
197
  const adapter = requireAdapter();
198
+ const { root } = scope;
186
199
  for (const entry of entries) {
187
200
  const parsed = adapter.readEntry(entry.pluginData);
188
201
  if (!parsed || parsed.priority === priority) continue;
@@ -199,12 +212,13 @@ async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
199
212
  });
200
213
  }
201
214
  }
202
- async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, root }) {
215
+ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, scope }) {
203
216
  const adapter = requireAdapter();
217
+ const { root } = scope;
204
218
  try {
205
- const existing = await findActiveEntries(slug, itemId, root);
219
+ const existing = await findActiveEntries(slug, itemId, scope);
206
220
  if (existing.length > 0) {
207
- await reconcileEntrySeverity(slug, itemId, existing, priority, root);
221
+ await reconcileEntrySeverity(slug, itemId, existing, priority, scope);
208
222
  return;
209
223
  }
210
224
  const navigateTarget = adapter.buildNavigateTarget(slug, itemId, root);
@@ -234,9 +248,18 @@ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displa
234
248
  /** Idempotently clear EVERY bell entry that matches this (slug, itemId).
235
249
  * Silent no-op when nothing matches. The "every" is defensive: if a
236
250
  * duplicate ever slips through, this drains the lot. */
237
- async function clearItemNotification(slug, itemId, root) {
251
+ async function clearItemNotification(slug, itemId, root, aid) {
252
+ await clearScoped(slug, itemId, {
253
+ root,
254
+ aid
255
+ });
256
+ }
257
+ /** The scoped form every internal caller uses. `clearItemNotification` keeps
258
+ * its positional `(slug, itemId, root?)` shape because it is public API
259
+ * (MulmoTerminal calls it); `aid` is additive. */
260
+ async function clearScoped(slug, itemId, scope) {
238
261
  try {
239
- const ids = await findActiveEntryIds(slug, itemId, root);
262
+ const ids = await findActiveEntryIds(slug, itemId, scope);
240
263
  for (const entryId of ids) await clear(entryId);
241
264
  } catch (err) {
242
265
  log().warn("notify clear failed", {
@@ -256,19 +279,19 @@ async function clearItemNotification(slug, itemId, root) {
256
279
  * `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */
257
280
  async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
258
281
  const { slug, schema } = collection;
259
- const root = ioOpts.workspaceRoot;
282
+ const scope = bellScopeOf(collection, ioOpts);
260
283
  if (!schema.completionField) {
261
- await clearItemNotification(slug, itemId, root);
284
+ await clearScoped(slug, itemId, scope);
262
285
  return;
263
286
  }
264
287
  const item = await storeFor(collection, ioOpts).read(itemId);
265
288
  if (item === null) {
266
- await clearItemNotification(slug, itemId, root);
289
+ await clearScoped(slug, itemId, scope);
267
290
  return;
268
291
  }
269
292
  await maybeSpawnSuccessor(collection, item, itemId, ioOpts);
270
293
  if (itemIsDone(schema, item)) {
271
- await clearItemNotification(slug, itemId, root);
294
+ await clearScoped(slug, itemId, scope);
272
295
  return;
273
296
  }
274
297
  if (schema.triggerField) {
@@ -280,15 +303,15 @@ async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
280
303
  triggerField: schema.triggerField
281
304
  });
282
305
  if (due !== true) {
283
- await clearItemNotification(slug, itemId, root);
306
+ await clearScoped(slug, itemId, scope);
284
307
  return;
285
308
  }
286
309
  }
287
310
  if (!whenMatches(schema.notifyWhen, item)) {
288
- await clearItemNotification(slug, itemId, root);
311
+ await clearScoped(slug, itemId, scope);
289
312
  return;
290
313
  }
291
- await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), root);
314
+ await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), scope);
292
315
  }
293
316
  /** Boot-time reconcile: walk every record of the collection once (through
294
317
  * its store) and reconcile it. Catches up changes that happened while the
@@ -334,7 +357,7 @@ async function reconcileAllItems(collection, ioOpts = {}, now = evalNow()) {
334
357
  * ever clear, while the reconcile publishes a second rooted one beside it.
335
358
  * Clearing converges — the record republishes rooted if still pending. */
336
359
  function sweepVerdict(entry, ownRoot) {
337
- if (entry.aid !== void 0) return "skip";
360
+ if (entry.aid !== void 0) return "shared";
338
361
  if (entry.root === ownRoot) return "mine";
339
362
  if (entry.root === void 0) return "drop-legacy";
340
363
  return "skip";
@@ -344,12 +367,40 @@ function sweepVerdict(entry, ownRoot) {
344
367
  * `notifyWhen`. The reverse of the reconcile invariant, applied to an entry
345
368
  * whose record the forward pass can never walk to (a delete leaves a bell that
346
369
  * a walk over the SURVIVING records cannot clear). */
347
- async function isStaleEntry(slug, itemId, opts) {
370
+ async function isStaleEntry(slug, itemId, opts, aid) {
348
371
  const collection = await loadCollection(slug, opts);
372
+ if (aid !== void 0 && collection?.appId !== aid) return false;
349
373
  if (!collection || !collection.schema.completionField) return true;
374
+ return recordNoLongerBells(collection, itemId, opts);
375
+ }
376
+ /** The record half of the staleness question: gone, done, or outside
377
+ * `notifyWhen`. Split out so the caller stays one decision — WHOSE entry this
378
+ * is — and this one stays the other. */
379
+ async function recordNoLongerBells(collection, itemId, opts) {
350
380
  const item = await storeFor(collection, opts).read(itemId);
351
381
  return item === null || itemIsDone(collection.schema, item) || !whenMatches(collection.schema.notifyWhen, item);
352
382
  }
383
+ /** One entry's fate: whose it is (`sweepVerdict`), then — when it is this
384
+ * sweep's to judge — whether its record still justifies the bell. Extracted so
385
+ * the loop above stays a walk and this stays the decision. */
386
+ async function sweepOneEntry(entryId, parsed, ownRoot, opts) {
387
+ const verdict = sweepVerdict(parsed, ownRoot);
388
+ if (verdict === "skip") return;
389
+ if (verdict === "drop-legacy") {
390
+ await clear(entryId);
391
+ return;
392
+ }
393
+ const { slug, itemId } = parsed;
394
+ try {
395
+ if (await isStaleEntry(slug, itemId, opts, verdict === "shared" ? parsed.aid : void 0)) await clear(entryId);
396
+ } catch (err) {
397
+ log().warn("sweep entry failed", {
398
+ slug,
399
+ itemId,
400
+ error: errMsg(err)
401
+ });
402
+ }
403
+ }
353
404
  async function sweepStaleActiveEntries(opts = {}) {
354
405
  const adapter = requireAdapter();
355
406
  let entries;
@@ -365,22 +416,7 @@ async function sweepStaleActiveEntries(opts = {}) {
365
416
  if (!own) continue;
366
417
  const parsed = parseCompletionLegacyId(own.legacyId);
367
418
  if (!parsed) continue;
368
- const verdict = sweepVerdict(parsed, ownRoot);
369
- if (verdict === "skip") continue;
370
- if (verdict === "drop-legacy") {
371
- await clear(entry.id);
372
- continue;
373
- }
374
- const { slug, itemId } = parsed;
375
- try {
376
- if (await isStaleEntry(slug, itemId, opts)) await clear(entry.id);
377
- } catch (err) {
378
- log().warn("sweep entry failed", {
379
- slug,
380
- itemId,
381
- error: errMsg(err)
382
- });
383
- }
419
+ await sweepOneEntry(entry.id, parsed, ownRoot, opts);
384
420
  }
385
421
  }
386
422
  /** Test-only: clear the per-key in-flight locks. */
@@ -590,9 +626,58 @@ async function _tickTimeTriggersForTesting(now, workspaceRoot) {
590
626
  async function tickTimeTriggers(gen, now = evalNow()) {
591
627
  for (const entry of gen.watchers.values()) {
592
628
  const { schema } = entry.collection;
629
+ if (cannotReportChanges(gen, entry.collection)) continue;
593
630
  if (!schema.triggerField && !schema.spawn) continue;
594
631
  await reconcileAllItems(entry.collection, gen.discoveryOpts, now);
595
632
  }
633
+ await tickUnwatchedCollections(gen, now);
634
+ }
635
+ /** True when the collection's store implements no `watch` — nothing will ever
636
+ * tell this module its records moved, so the clock tick is its only change
637
+ * detection. A capability question, deliberately not a backend one: the day a
638
+ * shared collection's store grows an `onSnapshot` watch, it stops being
639
+ * special here with no edit to this file. */
640
+ function cannotReportChanges(gen, collection) {
641
+ return storeFor(collection, gen.discoveryOpts).watch === void 0;
642
+ }
643
+ /** True when a schema declares behaviour that only a reconcile pass can
644
+ * produce: bells (`completionField`), date-triggered bells (`triggerField`),
645
+ * or recurrence successors (`spawn`). */
646
+ function needsReconcilePass(schema) {
647
+ return Boolean(schema.completionField ?? schema.triggerField ?? schema.spawn);
648
+ }
649
+ /** One unwatched collection's reconcile pass. Extracted so the single-flight
650
+ * callback doesn't close over loop state.
651
+ *
652
+ * `reconcileAllItems` already swallows a failing store read (it logs and
653
+ * returns), so a closed session or a denied rule surfaces there, not as a
654
+ * rejection here. This catch is only for the unexpected — it must not let one
655
+ * collection's fault abort the rest of the tick. */
656
+ async function reconcileUnwatched(gen, collection, now) {
657
+ try {
658
+ await runSingleFlight(gen.collectionSlots, collection.slug, () => reconcileAllItems(collection, gen.discoveryOpts, now));
659
+ } catch (err) {
660
+ log().warn("unwatched collection reconcile failed", {
661
+ slug: collection.slug,
662
+ error: errMsg(err)
663
+ });
664
+ }
665
+ }
666
+ /** Stand in for the store-change path (2) on backends that don't have one.
667
+ *
668
+ * Gated on `needsReconcilePass` so a collection declaring none of it costs
669
+ * nothing: unlike the local backends, every pass here is a network round trip.
670
+ *
671
+ * A collection that DROPS OUT of eligibility (its `completionField` edited
672
+ * away, or the collection removed) is not this pass's problem — `syncWatchers`
673
+ * already sweeps on a changed schema and on a vanished slug, and the sweep is
674
+ * what clears bells the declaration no longer justifies. */
675
+ async function tickUnwatchedCollections(gen, now) {
676
+ if (firestoreHandle() === null) return;
677
+ const pending = [...gen.watchers.values()].map((entry) => entry.collection).filter((collection) => cannotReportChanges(gen, collection) && needsReconcilePass(collection.schema));
678
+ if (pending.length === 0) return;
679
+ for (const collection of pending) await reconcileUnwatched(gen, collection, now);
680
+ await sweepStaleActiveEntries(gen.discoveryOpts);
596
681
  }
597
682
  /** Reconcile the watcher set against the currently-discovered
598
683
  * collections. Adds watchers for new slugs (with a boot reconcile of
@@ -633,7 +718,13 @@ function stopVanishedWatchers(gen, liveSlugs) {
633
718
  * modes. The mounted fs.watch is bound to the OLD location, so it must
634
719
  * be remounted, not just re-reconciled. */
635
720
  function storagePathChanged(previous, next) {
636
- return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.path !== next.storage?.path;
721
+ return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.type !== next.storage?.type || storageFilePath(previous.storage) !== storageFilePath(next.storage);
722
+ }
723
+ /** The on-disk path of a storage backend, or undefined when it has none (a
724
+ * shared collection keeps its records off this filesystem, so there is no
725
+ * mount to move — only a `type` change matters for it). */
726
+ function storageFilePath(storage) {
727
+ return storage?.type === "sqlite" ? storage.path : void 0;
637
728
  }
638
729
  /** Re-reconcile already-watched collections whose schema changed since
639
730
  * the last tick. New collections fall through to `startNewWatchers`. */