@mulmoclaude/core 3.7.0 → 3.10.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 (74) hide show
  1. package/dist/collection/core/itemId.d.ts +28 -0
  2. package/dist/collection/firestore.cjs +9 -1
  3. package/dist/collection/firestore.cjs.map +1 -1
  4. package/dist/collection/firestore.js +10 -2
  5. package/dist/collection/firestore.js.map +1 -1
  6. package/dist/collection/index.cjs +55 -66
  7. package/dist/collection/index.cjs.map +1 -1
  8. package/dist/collection/index.d.ts +1 -1
  9. package/dist/collection/index.js +3 -15
  10. package/dist/collection/index.js.map +1 -1
  11. package/dist/collection/registry/server/index.cjs +19 -19
  12. package/dist/collection/registry/server/index.cjs.map +1 -1
  13. package/dist/collection/registry/server/index.js +2 -2
  14. package/dist/collection/server/firestoreDocs.d.ts +18 -0
  15. package/dist/collection/server/host.d.ts +29 -0
  16. package/dist/collection/server/index.cjs +70 -62
  17. package/dist/collection/server/index.d.ts +2 -3
  18. package/dist/collection/server/index.js +4 -4
  19. package/dist/collection/server/io.d.ts +5 -3
  20. package/dist/collection/server/manageTool.d.ts +0 -4
  21. package/dist/collection/server/publishProject.d.ts +171 -2
  22. package/dist/collection-watchers/index.cjs +82 -38
  23. package/dist/collection-watchers/index.cjs.map +1 -1
  24. package/dist/collection-watchers/index.js +67 -23
  25. package/dist/collection-watchers/index.js.map +1 -1
  26. package/dist/{store-_61sO8K8.js → discovery-B9zdNkgW.js} +2525 -2354
  27. package/dist/discovery-B9zdNkgW.js.map +1 -0
  28. package/dist/{store-5_P_NsGa.cjs → discovery-BsXiDZBR.cjs} +2545 -2362
  29. package/dist/discovery-BsXiDZBR.cjs.map +1 -0
  30. package/dist/feeds/index.cjs +4 -4
  31. package/dist/feeds/index.js +2 -2
  32. package/dist/feeds/server/index.cjs +14 -14
  33. package/dist/feeds/server/index.cjs.map +1 -1
  34. package/dist/feeds/server/index.js +4 -4
  35. package/dist/firestore/listen.d.ts +4 -0
  36. package/dist/google/index.cjs +16 -16
  37. package/dist/google/index.cjs.map +1 -1
  38. package/dist/google/index.js +2 -2
  39. package/dist/{ingestTypes-CEi-Ot7F.js → ingestTypes-B312BwXg.js} +2 -2
  40. package/dist/{ingestTypes-CEi-Ot7F.js.map → ingestTypes-B312BwXg.js.map} +1 -1
  41. package/dist/{ingestTypes-iaiq33Ph.cjs → ingestTypes-B4XfrUoq.cjs} +3 -3
  42. package/dist/{ingestTypes-iaiq33Ph.cjs.map → ingestTypes-B4XfrUoq.cjs.map} +1 -1
  43. package/dist/{calendarGrid-CQ8MVSRb.js → itemId-DfTm08jm.js} +34 -2
  44. package/dist/itemId-DfTm08jm.js.map +1 -0
  45. package/dist/{calendarGrid-DGILaVxI.cjs → itemId-Dzrr3vs5.cjs} +45 -1
  46. package/dist/itemId-Dzrr3vs5.cjs.map +1 -0
  47. package/dist/listen-D5av7GzB.js +22 -0
  48. package/dist/listen-D5av7GzB.js.map +1 -0
  49. package/dist/listen-UZA68Upt.cjs +45 -0
  50. package/dist/listen-UZA68Upt.cjs.map +1 -0
  51. package/dist/{promptSafety-CpiME8pj.js → promptSafety-CSdyp7RF.js} +2 -2
  52. package/dist/{promptSafety-CpiME8pj.js.map → promptSafety-CSdyp7RF.js.map} +1 -1
  53. package/dist/{promptSafety-NNGViiCr.cjs → promptSafety-CWqYq-NS.cjs} +8 -8
  54. package/dist/{promptSafety-NNGViiCr.cjs.map → promptSafety-CWqYq-NS.cjs.map} +1 -1
  55. package/dist/remote-host/server/hostRunner.d.ts +2 -4
  56. package/dist/remote-host/server/index.cjs +4 -19
  57. package/dist/remote-host/server/index.cjs.map +1 -1
  58. package/dist/remote-host/server/index.js +1 -16
  59. package/dist/remote-host/server/index.js.map +1 -1
  60. package/dist/{server-B48Jyxcj.js → server-BNe8XMtu.js} +388 -568
  61. package/dist/server-BNe8XMtu.js.map +1 -0
  62. package/dist/{server-CWZyg8fn.cjs → server-CgkkWWBd.cjs} +590 -734
  63. package/dist/server-CgkkWWBd.cjs.map +1 -0
  64. package/dist/whisper/index.cjs +1 -1
  65. package/dist/whisper/index.js +1 -1
  66. package/package.json +1 -1
  67. package/dist/calendarGrid-CQ8MVSRb.js.map +0 -1
  68. package/dist/calendarGrid-DGILaVxI.cjs.map +0 -1
  69. package/dist/collection/core/shortHexId.d.ts +0 -8
  70. package/dist/collection/server/publish.d.ts +0 -56
  71. package/dist/server-B48Jyxcj.js.map +0 -1
  72. package/dist/server-CWZyg8fn.cjs.map +0 -1
  73. package/dist/store-5_P_NsGa.cjs.map +0 -1
  74. package/dist/store-_61sO8K8.js.map +0 -1
@@ -2,9 +2,9 @@ const require_rolldown_runtime = require("./rolldown-runtime-VH7oDXx4.cjs");
2
2
  const require_dist = require("./dist-CsgSfWwR.cjs");
3
3
  const require_root = require("./root-rPH6FGDT.cjs");
4
4
  const require_relPath = require("./relPath-CTAjGdCL.cjs");
5
- const require_calendarGrid = require("./calendarGrid-DGILaVxI.cjs");
6
- const require_promptSafety = require("./promptSafety-NNGViiCr.cjs");
7
- const require_store = require("./store-5_P_NsGa.cjs");
5
+ const require_itemId = require("./itemId-Dzrr3vs5.cjs");
6
+ const require_promptSafety = require("./promptSafety-CWqYq-NS.cjs");
7
+ const require_discovery = require("./discovery-BsXiDZBR.cjs");
8
8
  const require_feeds_paths = require("./feeds/paths.cjs");
9
9
  const require_skill_bridge_index = require("./skill-bridge/index.cjs");
10
10
  let node_path = require("node:path");
@@ -12,13 +12,11 @@ node_path = require_rolldown_runtime.__toESM(node_path, 1);
12
12
  let node_crypto = require("node:crypto");
13
13
  let node_fs_promises = require("node:fs/promises");
14
14
  let zod = require("zod");
15
- let node_child_process = require("node:child_process");
16
- let node_util = require("node:util");
17
15
  //#region src/collection/server/publishManifest.ts
18
16
  /** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
19
17
  * that `sharedCollectionKey` applies. Stated once so a path built later
20
18
  * cannot be a way around it. */
21
- var NameZ = zod.z.string().refine(require_calendarGrid.isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
19
+ var NameZ = zod.z.string().refine(require_itemId.isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
22
20
  /** An address on the roster. Not validated as an email beyond "has an @":
23
21
  * the rules compare it to `request.auth.token.email` verbatim, so any
24
22
  * narrowing here would refuse addresses Firebase itself accepts. */
@@ -154,7 +152,7 @@ var AuthoredAppZ = zod.z.object({
154
152
  * once — publish is a manual step, and a parser that stops at the first key
155
153
  * makes it N round trips. */
156
154
  function parseAuthoredApp(raw) {
157
- const manifest = require_store.parseAppManifest(raw);
155
+ const manifest = require_discovery.parseAppManifest(raw);
158
156
  if (!manifest.ok) return {
159
157
  ok: false,
160
158
  problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
@@ -265,6 +263,7 @@ function projectApp(authored, schemas, stamp, existing) {
265
263
  publishedAt: stamp.publishedAt,
266
264
  publishedBy: stamp.email,
267
265
  publishedCommit: stamp.commit,
266
+ publishedDirty: stamp.dirty === true ? true : void 0,
268
267
  previousPublished: previousOf(existing)
269
268
  });
270
269
  const config = {
@@ -283,6 +282,102 @@ function projectApp(authored, schemas, stamp, existing) {
283
282
  config
284
283
  };
285
284
  }
285
+ /** Keys on the app document that publish owns: what is PUBLIC right now, and
286
+ * the rule-facing configuration anonymous access is judged against. Deploy
287
+ * carries them through from the existing document and never authors them. */
288
+ var PUBLISH_OWNED_KEYS = [
289
+ "public",
290
+ "collections",
291
+ "participantRead",
292
+ "publishedAt",
293
+ "publishedBy",
294
+ "publishedCommit",
295
+ "publishedDirty",
296
+ "previousPublished"
297
+ ];
298
+ var isPublishOwned = (key) => PUBLISH_OWNED_KEYS.includes(key);
299
+ function projectDeploy(authored, schemas, stamp, existing) {
300
+ const { app } = projectApp(authored, schemas, stamp, existing);
301
+ const deployed = Object.fromEntries(Object.entries(app).filter(([key]) => !isPublishOwned(key)));
302
+ for (const key of PUBLISH_OWNED_KEYS) {
303
+ const live = existing?.[key];
304
+ if (live !== void 0) deployed[key] = live;
305
+ }
306
+ deployed.deployedAt = stamp.publishedAt;
307
+ deployed.deployedBy = stamp.email;
308
+ if (stamp.commit !== void 0) deployed.deployedCommit = stamp.commit;
309
+ return {
310
+ app: deployed,
311
+ staging: schemas.map(({ cid, schema }) => ({
312
+ cid,
313
+ doc: stagedDoc(schema, stamp, authored, cid)
314
+ }))
315
+ };
316
+ }
317
+ /** One staged schema document, carrying this cid's rule-facing configuration
318
+ * alongside the schema so publish can promote them together. */
319
+ function stagedDoc(schema, stamp, authored, cid) {
320
+ const doc = {
321
+ publishedSchema: schema,
322
+ deployedAt: stamp.publishedAt,
323
+ deployedBy: stamp.email
324
+ };
325
+ const config = authored.collections?.[cid];
326
+ if (config !== void 0) doc.config = config;
327
+ if (authored.participantRead?.includes(cid) === true) doc.participantRead = true;
328
+ if (stamp.commit !== void 0) doc.deployedCommit = stamp.commit;
329
+ return doc;
330
+ }
331
+ /** The rule-facing configuration to promote, read from the STAGED documents
332
+ * rather than from the manifest as it reads right now.
333
+ *
334
+ * Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
335
+ * the promoted schema is A's while the authorization behaviour is B's, a
336
+ * combination nobody exercised through `/staging/{aid}`.
337
+ *
338
+ * (`public` is deliberately NOT part of this: it is not staged, because it is
339
+ * the decision being made AT publish rather than something under test.) */
340
+ function stagedRuleConfig(staged) {
341
+ const entries = [];
342
+ const participantRead = [];
343
+ for (const entry of staged) {
344
+ const { config } = entry.doc;
345
+ if (config !== void 0) entries.push([entry.cid, config]);
346
+ if (entry.doc.participantRead === true) participantRead.push(entry.cid);
347
+ }
348
+ return {
349
+ collections: entries.length > 0 ? Object.fromEntries(entries) : void 0,
350
+ participantRead: participantRead.length > 0 ? participantRead : void 0
351
+ };
352
+ }
353
+ function projectPublish(authored, staged, stamp, existing) {
354
+ const { app, config } = projectApp(authored, [], stamp, existing);
355
+ const staging = stagedRuleConfig(staged);
356
+ app.collections = staging.collections;
357
+ app.participantRead = staging.participantRead;
358
+ const published = Object.fromEntries(Object.entries(existing ?? app).filter(([key]) => !isPublishOwned(key)));
359
+ for (const key of PUBLISH_OWNED_KEYS) if (key !== "public" && app[key] !== void 0) published[key] = app[key];
360
+ const publicBlock = app.public;
361
+ return {
362
+ app: published,
363
+ config,
364
+ public: require_dist.isRecord(publicBlock) ? publicBlock : void 0
365
+ };
366
+ }
367
+ /** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
368
+ *
369
+ * The stamp answers "which version is PUBLIC right now, and who made it so",
370
+ * so it is written by the operation that changes the answer — publish — not
371
+ * carried over from the deploy that staged it. */
372
+ function promoteSchema(staged, stamp) {
373
+ const doc = {
374
+ publishedSchema: staged.publishedSchema,
375
+ publishedAt: stamp.publishedAt,
376
+ publishedBy: stamp.email
377
+ };
378
+ if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
379
+ return doc;
380
+ }
286
381
  /** One published schema document. Written key by key rather than through
287
382
  * `compact`, so the declared type is the type — an optional commit is the
288
383
  * only variable part. */
@@ -298,8 +393,31 @@ function schemaDoc(schema, stamp) {
298
393
  /** The app documents' parent path — the `FirestoreDocs` seam takes a
299
394
  * collection path plus a document id, and the app document's id is the aid. */
300
395
  var APPS_COLLECTION = "apps";
301
- /** The collection (schema) documents' parent path. */
396
+ /** The collection (schema) documents' parent path — what the PUBLIC page
397
+ * reads, written only by publish (promotion). */
302
398
  var appSchemasPath = (aid) => `apps/${aid}/collections`;
399
+ /** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
400
+ *
401
+ * A TOP-LEVEL collection, not a field on the app: the public page resolves a
402
+ * slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
403
+ * has to be claimable atomically (create-if-absent) so two apps cannot hold
404
+ * the same URL.
405
+ *
406
+ * `published` is what makes the reservation invisible until publish. The slug
407
+ * is human-readable, so a readable reservation would let anyone guess the URL
408
+ * and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
409
+ * `allow read: if resource.data.published == true`, which needs no `get()` and
410
+ * so costs nothing against the rules' expression budget. */
411
+ var APP_SLUGS_COLLECTION = "appSlugs";
412
+ var appSlugDoc = (aid, published) => ({
413
+ aid,
414
+ published
415
+ });
416
+ /** The staged schema documents' parent path — what `/staging/{aid}` reads,
417
+ * written by deploy. A separate DOCUMENT rather than a field beside
418
+ * `publishedSchema`, because the rules cannot hide a field: anything inside a
419
+ * document the public page may read is public. */
420
+ var appStagingPath = (aid) => `apps/${aid}/staging`;
303
421
  /** The public-config documents' parent path. */
304
422
  var appConfigPath = (aid) => `apps/${aid}/config`;
305
423
  //#endregion
@@ -525,530 +643,32 @@ function publishProblems(app, collections, publisherEmail) {
525
643
  ...primaryKeyProblems(app, collections)
526
644
  ];
527
645
  }
528
- /** A public submission must be able to produce a record the HOST can read.
646
+ /** A public submission must NOT be allowed to name its own primary key.
647
+ *
648
+ * The rules constrain the DOCUMENT ID (`idFrom`) and cannot constrain the
649
+ * value of a field — nothing compares `request.resource.data[primaryKey]`
650
+ * with the path being written. So a submit path that accepts the primary key
651
+ * as a `createField` lets a submitter write at their one permitted document
652
+ * id while CLAIMING another record's identity, or a duplicate.
529
653
  *
530
- * The rules and the engine disagree about what identifies a record, and the
531
- * gap is invisible from either side alone. The rules bind the DOCUMENT ID
532
- * (`idFrom`) and let a submission carry only `createFields`; the engine
533
- * identifies a record by its schema's `primaryKey` FIELD, and the firestore
534
- * store hands back the document's fields verbatim — `toItem` does not
535
- * synthesize the key from the document id. So a submit path whose
536
- * `createFields` omits the primary key writes rows that Firestore accepts and
537
- * every reader rejects: `validateRecordObject` fails them, the collection
538
- * renders empty-ish, and the next publish's own pre-check reports them as
539
- * broken records the publisher never wrote.
654
+ * It is refused rather than tolerated because there is nothing for the field
655
+ * to do: `firestoreStore` takes a shared record's identity from the document
656
+ * id and overwrites the field on read, so a submitted value is either equal
657
+ * to the id (noise) or a lie (silently discarded). Publishing a form field
658
+ * whose value is thrown away is worse than not having it — the author will
659
+ * believe submitters choose their ids.
540
660
  *
541
- * Not checkable by the rules (they have never heard of a schema) and not
542
- * catchable at write time (nothing is wrong with the write). Publish is the
543
- * only place that holds both halves. */
661
+ * This is the second answer to the same question. The first was the reverse —
662
+ * REQUIRE the key, because a record without one was rejected by every reader
663
+ * — and it was right about the symptom and wrong about the cure: the identity
664
+ * belongs to the id the rules can pin, not to a field they cannot. */
544
665
  function primaryKeyProblems(app, collections) {
545
666
  const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
546
667
  return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
547
668
  const primaryKey = primaryKeyOf.get(cid);
548
- if (primaryKey === void 0 || submit.createFields.includes(primaryKey)) return [];
549
- return [`public.submit.${cid}.createFields must include "${primaryKey}", the schema's primaryKey: a submission may carry only the createFields, and a shared record is stored as exactly the fields it was written with — the document id is not copied into the record. Without it every submission is accepted by the rules and then rejected by every reader.`];
550
- });
551
- }
552
- //#endregion
553
- //#region src/collection/core/recordZ.ts
554
- /** The emptiness rule shared by `required` and the "only check present
555
- * values" gate. NOT a truthiness check — `0` and `false` are filled. */
556
- var isEmptyValue = (value) => value === void 0 || value === null || value === "";
557
- /** The historical write-gate checks, verbatim: required non-empty, enum
558
- * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
559
- function enforcedProblem(key, spec, value) {
560
- const empty = isEmptyValue(value);
561
- if (spec.required && empty) return `missing required field '${key}'`;
562
- if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
563
- return null;
564
- }
565
- /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
566
- * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
567
- * the lint flags exactly the values the calendar / trigger / spawn code
568
- * would silently drop — impossible days like `2026-02-30`, and datetimes
569
- * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
570
- * which the day view can't place). `string`-backed types accept anything
571
- * stringifiable; `ref` existence is out of scope. */
572
- function strictTypeProblem(key, spec, value) {
573
- switch (spec.type) {
574
- case "number":
575
- case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
576
- case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
577
- case "date": return require_calendarGrid.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
578
- case "datetime": return require_calendarGrid.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
579
- default: return null;
580
- }
581
- }
582
- /** Strict check for a PRESENT `table` value: an array of row objects, each
583
- * row conforming to the sub-schema (required / enum / typed sub-values).
584
- * First row problem wins, prefixed with the row number so the fix is
585
- * locatable. */
586
- function strictTableProblem(key, spec, value) {
587
- if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
588
- for (let index = 0; index < value.length; index++) {
589
- const row = value[index];
590
- if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
591
- for (const [subKey, subSpec] of Object.entries(spec.of)) {
592
- const subValue = row[subKey];
593
- const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
594
- if (problem) return `'${key}' row ${index + 1}: ${problem}`;
595
- }
596
- }
597
- return null;
598
- }
599
- /** First problem for one field's stored value under `tier`, or null.
600
- * Enforced checks always run (and their messages never vary by tier — the
601
- * scan and the write gate must agree on them); strict adds the per-type
602
- * layer on present values only. */
603
- function recordFieldProblem(key, spec, value, tier) {
604
- const enforced = enforcedProblem(key, spec, value);
605
- if (enforced || tier === "enforced") return enforced;
606
- if (isEmptyValue(value)) return null;
607
- if (spec.type === "table") return strictTableProblem(key, spec, value);
608
- return strictTypeProblem(key, spec, value);
609
- }
610
- var compiled = /* @__PURE__ */ new WeakMap();
611
- /** Compile `schema.fields` into a zod validator for a stored record.
612
- * Loose object: unknown keys are allowed and any declared key may be
613
- * absent (records are user files, not parse-and-rewrite targets —
614
- * callers validate, they never persist the parse output). The checks run
615
- * as ONE object-level refine iterating fields in declaration order —
616
- * per-key shape schemas can't express "key may be absent BUT its absence
617
- * must still reach the required check", and the single loop keeps the
618
- * first reported issue identical to the historical first-problem-wins
619
- * contract. */
620
- function compileRecordZ(schema, tier) {
621
- const cached = compiled.get(schema)?.[tier];
622
- if (cached) return cached;
623
- const stored = Object.entries(schema.fields).filter(([, spec]) => !require_calendarGrid.COMPUTED_TYPES.has(spec.type));
624
- const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
625
- for (const [key, spec] of stored) {
626
- const problem = recordFieldProblem(key, spec, record[key], tier);
627
- if (problem) ctx.addIssue({
628
- code: "custom",
629
- message: problem,
630
- path: [key]
631
- });
632
- }
669
+ if (primaryKey === void 0 || !submit.createFields.includes(primaryKey)) return [];
670
+ return [`public.submit.${cid}.createFields must NOT include "${primaryKey}", the schema's primaryKey: the rules can pin the document id but not the value of a field, so a submitter could write at their own id while claiming another record's. A shared record's identity is its document id — the store fills the field from it, and a submitted value is either the same thing or a lie that is thrown away.`];
633
671
  });
634
- const entry = compiled.get(schema) ?? {};
635
- entry[tier] = validator;
636
- compiled.set(schema, entry);
637
- return validator;
638
- }
639
- /** First schema problem on an in-memory record under `tier`, or null. One
640
- * issue per record keeps the report short and the fix obvious (the
641
- * historical contract of `validateRecordObject`). */
642
- function firstRecordProblem(record, schema, tier) {
643
- const result = compileRecordZ(schema, tier).safeParse(record);
644
- if (result.success) return null;
645
- return result.error.issues[0]?.message ?? "record failed schema validation";
646
- }
647
- //#endregion
648
- //#region src/collection/server/validate.ts
649
- /** Don't flood the result; the first batch is enough to act on. Exported
650
- * because a caller that REPORTS a count has to know the count is a floor —
651
- * `publish` presents a full batch as "at least N" rather than as a total. */
652
- var MAX_RECORD_ISSUES = 25;
653
- /** The `file` of the pseudo-issue reported when the backend could not be read
654
- * at all.
655
- *
656
- * Exported because it is a DIFFERENT KIND of answer from "this record is
657
- * invalid", and a caller that treats the two alike gets it wrong in the
658
- * direction that matters: `publish` lets the user override invalid records,
659
- * and overriding this one would mean publishing without ever having looked. */
660
- var STORE_UNREADABLE = "(store)";
661
- var MAX_ISSUES = 25;
662
- /** Read every `<id>.json` under the collection's dataDir and report the
663
- * ones that won't load or violate the schema. An empty list means every
664
- * record is fine. */
665
- /** List entries under the data dir, guarding realpath containment (against a
666
- * symlinked dir swapped in after discovery, like `listItems`) and treating a
667
- * missing dir as empty while surfacing real I/O faults. */
668
- async function listRecordFilenames(dataDir, workspaceRoot) {
669
- if (!require_store.isContainedInRoot(dataDir, workspaceRoot)) {
670
- require_store.log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
671
- return [];
672
- }
673
- try {
674
- return await (0, node_fs_promises.readdir)(dataDir);
675
- } catch (err) {
676
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
677
- throw err;
678
- }
679
- }
680
- async function validateCollectionRecords(collection, opts = {}) {
681
- if (collection.schema.dataSource !== void 0) return [];
682
- if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
683
- const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
684
- const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
685
- const issues = [];
686
- for (const name of entries.sort()) {
687
- if (!name.endsWith(".json") || name.startsWith(".")) continue;
688
- if (issues.length >= MAX_ISSUES) break;
689
- const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
690
- if (issue) issues.push(issue);
691
- }
692
- return issues;
693
- }
694
- /** Store-backed twin of the file scan: list every record through the
695
- * collection's store and lint it with the same "strict" report-only tier.
696
- * A row the store can't even parse is invisible here (the store skips
697
- * it), so the read/parse classifications of the file scan don't apply —
698
- * schema violations are what this catches. `file` carries the record id
699
- * (there is no per-record filename). */
700
- async function validateStoreRecords(collection, opts) {
701
- let items;
702
- try {
703
- items = await require_store.storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
704
- } catch (err) {
705
- return [{
706
- file: STORE_UNREADABLE,
707
- problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
708
- }];
709
- }
710
- const issues = [];
711
- for (const item of items) {
712
- if (issues.length >= MAX_ISSUES) break;
713
- const itemId = require_calendarGrid.fieldText(item[collection.schema.primaryKey]);
714
- const problem = validateRecordObject(item, itemId, collection.schema, "strict");
715
- if (problem) issues.push({
716
- file: itemId,
717
- problem
718
- });
719
- }
720
- return issues;
721
- }
722
- async function readRecordText(fullPath, name) {
723
- try {
724
- if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
725
- file: name,
726
- problem: "not a regular file (symlink?) — skipped, won't appear"
727
- };
728
- return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
729
- } catch {
730
- return {
731
- file: name,
732
- problem: "could not be read — skipped, won't appear"
733
- };
734
- }
735
- }
736
- /** Classify a single record file: unreadable / unparseable / non-object /
737
- * schema violation, or null when it's fine. */
738
- async function inspectRecord(fullPath, name, schema) {
739
- const read = await readRecordText(fullPath, name);
740
- if ("problem" in read) return read;
741
- let parsed;
742
- try {
743
- parsed = JSON.parse(read.raw);
744
- } catch (err) {
745
- return {
746
- file: name,
747
- problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
748
- };
749
- }
750
- if (!require_dist.isRecord(parsed)) return {
751
- file: name,
752
- problem: "not a JSON object — skipped, won't appear"
753
- };
754
- const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
755
- return problem ? {
756
- file: name,
757
- problem
758
- } : null;
759
- }
760
- /** What a non-string primary key actually is, for the error message. Names the
761
- * shape rather than stringifying the value — "[object Object]" tells the reader
762
- * nothing about what is wrong. */
763
- function describeIdType(value) {
764
- if (value === null) return "null";
765
- if (value === void 0) return "missing";
766
- if (Array.isArray(value)) return "an array";
767
- return `a ${typeof value}`;
768
- }
769
- /** First schema problem on an in-memory record (primaryKey↔id mismatch,
770
- * then the compiled per-field checks — see `../core/recordZ` for the two
771
- * tiers), or null when it's fine. One issue per record keeps the report
772
- * short and the fix obvious. Pure + exported so write paths
773
- * (manageCollection putItems) can gate on the SAME enforced rules the
774
- * post-hoc file scan reports — `itemId` is the id the record is (or
775
- * would be) stored under. The default `"enforced"` tier keeps every
776
- * write gate on the historical three checks; only pass `"strict"` from
777
- * report-only surfaces. */
778
- function validateRecordObject(record, itemId, schema, tier = "enforced") {
779
- const idValue = record[schema.primaryKey];
780
- if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
781
- if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
782
- return firstRecordProblem(record, schema, tier);
783
- }
784
- //#endregion
785
- //#region src/collection/server/publish.ts
786
- var execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
787
- /** How many broken records to name before summarising. A publish that would
788
- * break a thousand rows is answered by the count and a sample; dumping all of
789
- * them buries the number, which is the part the decision turns on. */
790
- var MAX_LISTED_ISSUES = 10;
791
- /** `git rev-parse HEAD` plus a dirty check, or nothing.
792
- *
793
- * A missing git, a repository with no commits and a non-repository are all
794
- * the same answer here — no commit — because the stamp is attribution, not a
795
- * requirement. What is NOT acceptable is a stamp that lies, which is why the
796
- * dirty flag exists: publishing from a modified tree records a commit that
797
- * does not describe what was published, and the flag is the only thing that
798
- * would ever tell a reader so. */
799
- async function gitStamp(root) {
800
- try {
801
- const { stdout } = await execFileAsync("git", [
802
- "-C",
803
- root,
804
- "rev-parse",
805
- "HEAD"
806
- ]);
807
- const commit = stdout.trim();
808
- const { stdout: status } = await execFileAsync("git", [
809
- "-C",
810
- root,
811
- "status",
812
- "--porcelain"
813
- ]);
814
- return {
815
- commit: commit.length > 0 ? commit : void 0,
816
- dirty: status.trim().length > 0
817
- };
818
- } catch {
819
- return {};
820
- }
821
- }
822
- /** The shared collections of THIS REPOSITORY, by cid.
823
- *
824
- * `userSkillsDir: null` — not a test convenience, a boundary. Discovery
825
- * resolves every schema it finds against the WORKSPACE root, user-scope
826
- * included, so a globally installed skill under `~/.claude/skills` carrying
827
- * `storage.type: "firestore"` picks up whichever repository's `aid` it
828
- * happens to be discovered from. Left in, publish would write that schema
829
- * into this app — and into every other app the same user publishes, since the
830
- * skill is installed once per machine and the repositories are not.
831
- *
832
- * An app is a REPOSITORY (design D1): its collections are the ones committed
833
- * beside its `app.json`, which is what makes a clone resolve the same
834
- * collections and an invitation a matter of authorization rather than
835
- * discovery. A schema that is not in the repository has no claim on a cid
836
- * there. And because a view is HTML, publishing one is not a tidiness
837
- * question: it is the machine's own skills reaching every member's browser.
838
- *
839
- * The consequence is deliberate: a cid named in `app.json` that exists only
840
- * in user scope is now an unknown cid, and publish says so by name instead of
841
- * quietly publishing a schema from outside the repository. */
842
- async function sharedCollections(opts, root) {
843
- return (await require_store.discoverCollections({
844
- ...opts,
845
- workspaceRoot: root,
846
- userSkillsDir: null
847
- })).filter((collection) => collection.appId !== void 0);
848
- }
849
- /** Existing records that would not satisfy the schema about to be published.
850
- *
851
- * Read from FIRESTORE, not from disk: a shared collection's records live in
852
- * the app, and the question this answers is "what does the live data look
853
- * like under the new schema" — which is the migration question. Reported as
854
- * a refusal the publisher can override, because a breaking change is
855
- * sometimes exactly what is intended and the point is that it is a decision
856
- * rather than a discovery. */
857
- async function recordProblems(collections, opts) {
858
- const lines = [];
859
- const unreadable = [];
860
- let records = 0;
861
- let cappedAnywhere = false;
862
- for (const collection of collections) {
863
- const issues = await validateCollectionRecords(collection, opts);
864
- if (issues.length === 0) continue;
865
- const unread = issues.filter((issue) => issue.file === STORE_UNREADABLE);
866
- if (unread.length > 0) {
867
- unreadable.push(`${collection.slug}: ${unread.map((issue) => issue.problem).join("; ")}`);
868
- continue;
869
- }
870
- records += issues.length;
871
- const capped = issues.length >= 25;
872
- cappedAnywhere = cappedAnywhere || capped;
873
- const count = capped ? `at least ${issues.length}` : String(issues.length);
874
- const plural = issues.length === 1 ? "" : "s";
875
- const note = capped ? " (the scan stops there)" : "";
876
- lines.push(`${collection.slug}: ${count} existing record${plural} would not satisfy the schema about to be published${note}`);
877
- for (const issue of issues.slice(0, MAX_LISTED_ISSUES)) lines.push(` - ${issue.file}: ${issue.problem}`);
878
- if (issues.length > MAX_LISTED_ISSUES) lines.push(` - … and ${issues.length - MAX_LISTED_ISSUES} more`);
879
- }
880
- return {
881
- lines,
882
- records,
883
- capped: cappedAnywhere,
884
- unreadable
885
- };
886
- }
887
- function schemasOf(collections) {
888
- return collections.map((collection) => ({
889
- cid: collection.slug,
890
- schema: collection.schema
891
- })).sort((left, right) => left.cid < right.cid ? -1 : left.cid > right.cid ? 1 : 0);
892
- }
893
- /** Read and parse `<root>/app.json`'s full declaration. */
894
- async function readAuthored(root) {
895
- let raw;
896
- try {
897
- raw = await (0, node_fs_promises.readFile)(node_path.default.join(root, require_store.APP_MANIFEST_FILE), "utf-8");
898
- } catch (err) {
899
- return {
900
- ok: false,
901
- problems: [`cannot read ${node_path.default.join(root, require_store.APP_MANIFEST_FILE)}: ${String(err)}`]
902
- };
903
- }
904
- return parseAuthoredApp(raw);
905
- }
906
- /** Everything wrong with the declaration itself, publisher included. */
907
- function declarationProblems(app, collections, handle) {
908
- const problems = publishProblems(app, collections.map((collection) => ({
909
- cid: collection.slug,
910
- primaryKey: collection.schema.primaryKey
911
- })), handle.email);
912
- if (app.owner !== void 0 && app.owner !== handle.uid) problems.push(`app.json declares owner "${app.owner}", which is not your uid (${handle.uid}). \`owner\` is stamped by publish and carried forward unchanged afterwards — remove it from app.json rather than maintaining it by hand.`);
913
- return problems;
914
- }
915
- /** Put the three kinds of document, in the order the rules require, and turn a
916
- * rejected write into the result type instead of letting it escape.
917
- *
918
- * Returns null when everything was written; a failure result otherwise.
919
- *
920
- * A raw rejection here would reach the agent as a tool crash rather than the
921
- * actionable text this tool promises. But "actionable" is a strong claim for
922
- * a half-finished publish, so the message ENUMERATES what landed rather than
923
- * summarising it: the order is app → every schema → config, and a summary
924
- * written for one failure point is wrong at the others. Saying "the roster
925
- * and configuration are live" after a SCHEMA write failed names a config
926
- * document this publish never wrote — which still holds whatever the last
927
- * publish left, and is exactly the state the caller is trying to repair. */
928
- async function writeDocuments(handle, aid, published) {
929
- const steps = [
930
- {
931
- what: `the app document (apps/${aid})`,
932
- run: () => handle.docs.set(APPS_COLLECTION, aid, published.app)
933
- },
934
- ...published.schemas.map(({ cid, doc }) => ({
935
- what: `the published schema for '${cid}'`,
936
- run: () => handle.docs.set(appSchemasPath(aid), cid, doc)
937
- })),
938
- {
939
- what: `the public config document (apps/${aid}/config/${PUBLIC_CONFIG_DOC})`,
940
- run: () => handle.docs.set(appConfigPath(aid), PUBLIC_CONFIG_DOC, published.config)
941
- }
942
- ];
943
- const landed = [];
944
- for (const [index, step] of steps.entries()) try {
945
- await step.run();
946
- landed.push(step.what);
947
- } catch (err) {
948
- const reason = err instanceof Error ? err.message : String(err);
949
- return {
950
- ok: false,
951
- partial: index > 0,
952
- problems: [`publish failed while writing ${step.what}: ${reason}`, ...partialState(landed, [step.what, ...steps.slice(index + 1).map((rest) => rest.what)])]
953
- };
954
- }
955
- return null;
956
- }
957
- /** What is live and what is not, listed rather than summarised.
958
- *
959
- * Two facts, and both matter for the repair: a document this publish wrote is
960
- * live NOW, and a document it did not write still holds what the LAST publish
961
- * left — which is not the same as being absent, and not the same as matching
962
- * the declaration that was just half-applied. */
963
- function partialState(landed, notWritten) {
964
- const repair = "Publishing again is the repair: the write is idempotent, and it re-does every step, including the ones that did land.";
965
- if (landed.length === 0) return [`Nothing was written. ${repair}`];
966
- return [
967
- `Written by this publish, and live now: ${landed.join("; ")}.`,
968
- `NOT written: ${notWritten.join("; ")} — ${notWritten.length === 1 ? "it still holds" : "they still hold"} whatever the previous publish left.`,
969
- repair
970
- ];
971
- }
972
- /** Publish this repository's declaration to its app.
973
- *
974
- * Everything variable is a parameter or comes from the host binding, so the
975
- * whole path is exercisable against an in-memory `FirestoreDocs` with no
976
- * network and no API key — which is the only way the conversion table gets
977
- * tested as a table. */
978
- async function publishApp(opts = {}) {
979
- const root = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
980
- const handle = require_store.firestoreHandle();
981
- if (!handle) return {
982
- ok: false,
983
- partial: false,
984
- problems: ["publish needs a signed-in Firestore session: connect remote-host first. Publishing writes the app's roster and configuration as the app's owner, which is an authenticated write."]
985
- };
986
- const authored = await readAuthored(root);
987
- if (!authored.ok) return {
988
- ...authored,
989
- partial: false
990
- };
991
- const collections = await sharedCollections(opts, root);
992
- const problems = declarationProblems(authored.app, collections, handle);
993
- if (problems.length > 0) return {
994
- ok: false,
995
- partial: false,
996
- problems
997
- };
998
- const issues = await recordProblems(collections, {
999
- ...opts,
1000
- workspaceRoot: root
1001
- });
1002
- if (issues.unreadable.length > 0) return {
1003
- ok: false,
1004
- partial: false,
1005
- problems: [...issues.unreadable, "publish stopped: the live records could not be read, so nothing checked whether the schemas about to be published still fit them. This is not something `confirm` overrides — confirming means accepting a known breakage, and here there is no reading at all. Fix the access (or the connection) and publish again."]
1006
- };
1007
- if (issues.records > 0 && opts.confirm !== true) return {
1008
- ok: false,
1009
- partial: false,
1010
- problems: [...issues.lines, "publish stopped: these records are live and members are reading them. Migrate them first, or re-run with confirm to publish the schema anyway and repair the records afterwards."]
1011
- };
1012
- return writePublished(authored.app, collections, handle, opts, root, issues);
1013
- }
1014
- /** The write half: stamp, project, and put the documents in the order the
1015
- * rules require. Split from the gate above so neither half hides the other —
1016
- * everything up to here can refuse, and nothing from here on does. */
1017
- async function writePublished(authored, collections, handle, opts, root, issues) {
1018
- const { aid } = authored;
1019
- let existing;
1020
- try {
1021
- existing = await handle.docs.get(APPS_COLLECTION, aid);
1022
- } catch (err) {
1023
- return {
1024
- ok: false,
1025
- partial: false,
1026
- problems: [`publish failed while reading the current app document (apps/${aid}): ${err instanceof Error ? err.message : String(err)}`, "Nothing was written. Publishing again is safe — this read only decides whether the app is created or updated."]
1027
- };
1028
- }
1029
- const stampSource = await (opts.resolveCommit ?? gitStamp)(root);
1030
- const stamp = {
1031
- uid: handle.uid,
1032
- email: handle.email,
1033
- publishedAt: (opts.now ?? Date.now)(),
1034
- commit: stampSource.commit
1035
- };
1036
- const existingApp = require_dist.isRecord(existing) ? existing : null;
1037
- const published = projectApp(authored, schemasOf(collections), stamp, existingApp);
1038
- if (stampSource.dirty === true) published.app.publishedDirty = true;
1039
- const written = await writeDocuments(handle, aid, published);
1040
- if (written !== null) return written;
1041
- return {
1042
- ok: true,
1043
- aid,
1044
- cids: published.schemas.map((entry) => entry.cid),
1045
- created: existingApp === null,
1046
- commit: stamp.commit,
1047
- dirty: stampSource.dirty === true,
1048
- recordIssues: issues.records,
1049
- recordIssuesCapped: issues.capped,
1050
- published
1051
- };
1052
672
  }
1053
673
  //#endregion
1054
674
  //#region src/collection/server/skillAssets.ts
@@ -1075,13 +695,13 @@ async function readCustomViewHtml(collection, viewFile, opts = {}) {
1075
695
  * `readCustomViewHtml`. Used by both `readCustomViewHtml` and
1076
696
  * `readCustomViewI18n` so the two stay in lockstep. */
1077
697
  async function readSourceAwareFile(collection, relPath, opts) {
1078
- const safeSlug = require_store.safeSlugName(collection.slug);
698
+ const safeSlug = require_discovery.safeSlugName(collection.slug);
1079
699
  if (safeSlug === null) return null;
1080
- const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
1081
- const staging = collection.source === "project" ? require_store.stagingSkillDir(workspaceRoot, safeSlug) : null;
700
+ const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
701
+ const staging = collection.source === "project" ? require_discovery.stagingSkillDir(workspaceRoot, safeSlug) : null;
1082
702
  const bases = staging === null ? [collection.skillDir] : [staging, collection.skillDir];
1083
703
  for (const base of bases) {
1084
- const resolved = require_store.resolveTemplatePath(base, relPath);
704
+ const resolved = require_discovery.resolveTemplatePath(base, relPath);
1085
705
  if (resolved === null) continue;
1086
706
  try {
1087
707
  return await (0, node_fs_promises.readFile)(resolved, "utf-8");
@@ -1139,9 +759,9 @@ async function readCustomViewI18n(collection, i18nFile, locale, opts = {}) {
1139
759
  * the file contents, or null when the path escapes the skill dir, the
1140
760
  * resolved target isn't a regular file, or the read fails. */
1141
761
  async function readSkillTemplate(skillDir, templateRelPath) {
1142
- const resolved = require_store.resolveTemplatePath(skillDir, templateRelPath);
762
+ const resolved = require_discovery.resolveTemplatePath(skillDir, templateRelPath);
1143
763
  if (resolved === null) return null;
1144
- if (!await require_store.isRegularFile(resolved)) return null;
764
+ if (!await require_discovery.isRegularFile(resolved)) return null;
1145
765
  try {
1146
766
  return await (0, node_fs_promises.readFile)(resolved, "utf-8");
1147
767
  } catch {
@@ -1283,11 +903,11 @@ function absentReferencedColumns(rows, query) {
1283
903
  /** Run a validated query over enriched collection rows. */
1284
904
  async function runQueryOverRows(rows, query) {
1285
905
  if (rows.length === 0) return emptyCollectionResult(query);
1286
- await (0, node_fs_promises.mkdir)(require_store.cacheDir(), {
906
+ await (0, node_fs_promises.mkdir)(require_discovery.cacheDir(), {
1287
907
  recursive: true,
1288
908
  mode: 448
1289
909
  });
1290
- const jsonlPath = node_path.default.join(require_store.cacheDir(), `q-${(0, node_crypto.randomBytes)(8).toString("hex")}.jsonl`);
910
+ const jsonlPath = node_path.default.join(require_discovery.cacheDir(), `q-${(0, node_crypto.randomBytes)(8).toString("hex")}.jsonl`);
1291
911
  try {
1292
912
  const nullPads = Object.fromEntries(absentReferencedColumns(rows, query).map((column) => [column, null]));
1293
913
  const handle = await (0, node_fs_promises.open)(jsonlPath, "wx", 384);
@@ -1303,115 +923,347 @@ async function runQueryOverRows(rows, query) {
1303
923
  } finally {
1304
924
  await handle.close();
1305
925
  }
1306
- const { sql, params } = require_store.compileJsonlQuery(query);
1307
- return (await require_store.queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, require_store.normalizeCsvValue(value)])));
926
+ const { sql, params } = require_discovery.compileJsonlQuery(query);
927
+ return (await require_discovery.queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, require_discovery.normalizeCsvValue(value)])));
1308
928
  } finally {
1309
929
  await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
1310
- if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") require_store.log.warn("collections", "temp JSONL cleanup failed", {
930
+ if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") require_discovery.log.warn("collections", "temp JSONL cleanup failed", {
1311
931
  path: jsonlPath,
1312
932
  error: String(err)
1313
933
  });
1314
- });
1315
- }
934
+ });
935
+ }
936
+ }
937
+ //#endregion
938
+ //#region src/collection/server/derive.ts
939
+ async function loadTarget(slug, opts) {
940
+ const target = await require_discovery.loadCollection(slug, opts);
941
+ if (!target) return null;
942
+ const items = await require_discovery.storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
943
+ const byId = {};
944
+ for (const item of items) {
945
+ const itemId = item[target.schema.primaryKey];
946
+ if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
947
+ }
948
+ return {
949
+ schema: target.schema,
950
+ byId
951
+ };
952
+ }
953
+ /** Load every ref/embed target and backlink source collection once.
954
+ * Unknown / unloadable targets are simply absent — downstream derefs
955
+ * resolve to null (em-dash) and backlinks to an empty row set, the
956
+ * same fail-soft the UI renders. */
957
+ async function loadLinkedTargets(schema, opts) {
958
+ const slugs = [.../* @__PURE__ */ new Set([
959
+ ...require_promptSafety.uniqueRefTargets(schema),
960
+ ...require_promptSafety.uniqueEmbedTargets(schema),
961
+ ...require_promptSafety.uniqueBacklinkSources(schema)
962
+ ])];
963
+ const loaded = {};
964
+ for (const slug of slugs) {
965
+ const target = await loadTarget(slug, opts);
966
+ if (target) loaded[slug] = target;
967
+ }
968
+ return loaded;
969
+ }
970
+ function toRefRecords(linked) {
971
+ return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
972
+ }
973
+ /** The matching source rows for one `backlinks` field, projected to the
974
+ * source primaryKey + `display` columns — so getItems on a
975
+ * heavily-referenced record stays a summary, not a dump of the source
976
+ * collection. Missing source ⇒ [] (fail-soft). The rows come from the
977
+ * DERIVED source records (`byId`), so `display`/`filter` on a derived
978
+ * source column (an invoice `total`) works. */
979
+ function projectBacklinks(field, schema, enriched, linked) {
980
+ const source = linked[field.from];
981
+ if (!source) return [];
982
+ const selfId = require_itemId.fieldText(enriched[schema.primaryKey]);
983
+ return require_promptSafety.backlinkRows(field, selfId, Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
984
+ }
985
+ /** Project the computed (never-stored) field kinds onto one derived
986
+ * record: `toggle` → boolean off its enum, `embed` → the target record
987
+ * (fixed `id` or per-record `idField`), or null when missing,
988
+ * `backlinks` → the matching source rows (see `projectBacklinks`). */
989
+ function projectComputed(schema, enriched, linked) {
990
+ for (const [key, field] of Object.entries(schema.fields)) {
991
+ if (field.type === "toggle" && field.field) enriched[key] = require_itemId.fieldText(enriched[field.field]) === field.onValue;
992
+ if (field.type === "embed" && field.to) {
993
+ const targetId = require_itemId.embedTargetId(field, enriched);
994
+ const target = require_promptSafety.ownProp(linked, field.to);
995
+ enriched[key] = targetId && target && require_promptSafety.ownProp(target.byId, targetId) || null;
996
+ }
997
+ if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
998
+ }
999
+ return enriched;
1000
+ }
1001
+ /** Resolve every rollup field onto a COPY of the record, BEFORE the
1002
+ * formula pass — a `derived` formula may reference rollup values as
1003
+ * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
1004
+ * machinery as backlinks, collapsed to a number: an unresolvable source
1005
+ * is null (a formula reading it fails soft to em-dash); an empty match
1006
+ * set is a real 0. Returns `record` unchanged when the schema declares
1007
+ * no rollups. */
1008
+ function projectRollups(schema, record, linked) {
1009
+ let out = record;
1010
+ for (const [key, field] of Object.entries(schema.fields)) {
1011
+ if (field.type !== "rollup") continue;
1012
+ if (out === record) out = { ...record };
1013
+ const source = linked[field.from];
1014
+ const selfId = require_itemId.fieldText(record[schema.primaryKey]);
1015
+ out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
1016
+ }
1017
+ return out;
1018
+ }
1019
+ /** Enrich records with every host-computed field: derived formulas
1020
+ * evaluated (cross-collection derefs included), toggles projected,
1021
+ * embeds resolved. Loads each linked collection ONCE per call. Input
1022
+ * records are not mutated. */
1023
+ async function enrichItems(collection, items, opts = {}) {
1024
+ const { schema } = collection;
1025
+ const linked = await loadLinkedTargets(schema, opts);
1026
+ const refRecords = toRefRecords(linked);
1027
+ return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
1028
+ }
1029
+ //#endregion
1030
+ //#region src/collection/server/queryRunner.ts
1031
+ async function runCollectionQuery(collection, query, opts = {}) {
1032
+ const store = require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot });
1033
+ if (store.query) return store.query(query);
1034
+ return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
1035
+ }
1036
+ //#endregion
1037
+ //#region src/collection/core/recordZ.ts
1038
+ /** The emptiness rule shared by `required` and the "only check present
1039
+ * values" gate. NOT a truthiness check — `0` and `false` are filled. */
1040
+ var isEmptyValue = (value) => value === void 0 || value === null || value === "";
1041
+ /** The historical write-gate checks, verbatim: required non-empty, enum
1042
+ * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
1043
+ function enforcedProblem(key, spec, value) {
1044
+ const empty = isEmptyValue(value);
1045
+ if (spec.required && empty) return `missing required field '${key}'`;
1046
+ if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
1047
+ return null;
1048
+ }
1049
+ /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
1050
+ * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
1051
+ * the lint flags exactly the values the calendar / trigger / spawn code
1052
+ * would silently drop — impossible days like `2026-02-30`, and datetimes
1053
+ * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
1054
+ * which the day view can't place). `string`-backed types accept anything
1055
+ * stringifiable; `ref` existence is out of scope. */
1056
+ function strictTypeProblem(key, spec, value) {
1057
+ switch (spec.type) {
1058
+ case "number":
1059
+ case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
1060
+ case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
1061
+ case "date": return require_itemId.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
1062
+ case "datetime": return require_itemId.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
1063
+ default: return null;
1064
+ }
1065
+ }
1066
+ /** Strict check for a PRESENT `table` value: an array of row objects, each
1067
+ * row conforming to the sub-schema (required / enum / typed sub-values).
1068
+ * First row problem wins, prefixed with the row number so the fix is
1069
+ * locatable. */
1070
+ function strictTableProblem(key, spec, value) {
1071
+ if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
1072
+ for (let index = 0; index < value.length; index++) {
1073
+ const row = value[index];
1074
+ if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
1075
+ for (const [subKey, subSpec] of Object.entries(spec.of)) {
1076
+ const subValue = row[subKey];
1077
+ const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
1078
+ if (problem) return `'${key}' row ${index + 1}: ${problem}`;
1079
+ }
1080
+ }
1081
+ return null;
1082
+ }
1083
+ /** First problem for one field's stored value under `tier`, or null.
1084
+ * Enforced checks always run (and their messages never vary by tier — the
1085
+ * scan and the write gate must agree on them); strict adds the per-type
1086
+ * layer on present values only. */
1087
+ function recordFieldProblem(key, spec, value, tier) {
1088
+ const enforced = enforcedProblem(key, spec, value);
1089
+ if (enforced || tier === "enforced") return enforced;
1090
+ if (isEmptyValue(value)) return null;
1091
+ if (spec.type === "table") return strictTableProblem(key, spec, value);
1092
+ return strictTypeProblem(key, spec, value);
1093
+ }
1094
+ var compiled = /* @__PURE__ */ new WeakMap();
1095
+ /** Compile `schema.fields` into a zod validator for a stored record.
1096
+ * Loose object: unknown keys are allowed and any declared key may be
1097
+ * absent (records are user files, not parse-and-rewrite targets —
1098
+ * callers validate, they never persist the parse output). The checks run
1099
+ * as ONE object-level refine iterating fields in declaration order —
1100
+ * per-key shape schemas can't express "key may be absent BUT its absence
1101
+ * must still reach the required check", and the single loop keeps the
1102
+ * first reported issue identical to the historical first-problem-wins
1103
+ * contract. */
1104
+ function compileRecordZ(schema, tier) {
1105
+ const cached = compiled.get(schema)?.[tier];
1106
+ if (cached) return cached;
1107
+ const stored = Object.entries(schema.fields).filter(([, spec]) => !require_itemId.COMPUTED_TYPES.has(spec.type));
1108
+ const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
1109
+ for (const [key, spec] of stored) {
1110
+ const problem = recordFieldProblem(key, spec, record[key], tier);
1111
+ if (problem) ctx.addIssue({
1112
+ code: "custom",
1113
+ message: problem,
1114
+ path: [key]
1115
+ });
1116
+ }
1117
+ });
1118
+ const entry = compiled.get(schema) ?? {};
1119
+ entry[tier] = validator;
1120
+ compiled.set(schema, entry);
1121
+ return validator;
1122
+ }
1123
+ /** First schema problem on an in-memory record under `tier`, or null. One
1124
+ * issue per record keeps the report short and the fix obvious (the
1125
+ * historical contract of `validateRecordObject`). */
1126
+ function firstRecordProblem(record, schema, tier) {
1127
+ const result = compileRecordZ(schema, tier).safeParse(record);
1128
+ if (result.success) return null;
1129
+ return result.error.issues[0]?.message ?? "record failed schema validation";
1316
1130
  }
1317
1131
  //#endregion
1318
- //#region src/collection/server/derive.ts
1319
- async function loadTarget(slug, opts) {
1320
- const target = await require_store.loadCollection(slug, opts);
1321
- if (!target) return null;
1322
- const items = await require_store.storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
1323
- const byId = {};
1324
- for (const item of items) {
1325
- const itemId = item[target.schema.primaryKey];
1326
- if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
1132
+ //#region src/collection/server/validate.ts
1133
+ /** Don't flood the result; the first batch is enough to act on. Exported
1134
+ * because a caller that REPORTS a count has to know the count is a floor —
1135
+ * `publish` presents a full batch as "at least N" rather than as a total. */
1136
+ var MAX_RECORD_ISSUES = 25;
1137
+ /** The `file` of the pseudo-issue reported when the backend could not be read
1138
+ * at all.
1139
+ *
1140
+ * Exported because it is a DIFFERENT KIND of answer from "this record is
1141
+ * invalid", and a caller that treats the two alike gets it wrong in the
1142
+ * direction that matters: `publish` lets the user override invalid records,
1143
+ * and overriding this one would mean publishing without ever having looked. */
1144
+ var STORE_UNREADABLE = "(store)";
1145
+ var MAX_ISSUES = 25;
1146
+ /** Read every `<id>.json` under the collection's dataDir and report the
1147
+ * ones that won't load or violate the schema. An empty list means every
1148
+ * record is fine. */
1149
+ /** List entries under the data dir, guarding realpath containment (against a
1150
+ * symlinked dir swapped in after discovery, like `listItems`) and treating a
1151
+ * missing dir as empty while surfacing real I/O faults. */
1152
+ async function listRecordFilenames(dataDir, workspaceRoot) {
1153
+ if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) {
1154
+ require_discovery.log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
1155
+ return [];
1327
1156
  }
1328
- return {
1329
- schema: target.schema,
1330
- byId
1331
- };
1332
- }
1333
- /** Load every ref/embed target and backlink source collection once.
1334
- * Unknown / unloadable targets are simply absent — downstream derefs
1335
- * resolve to null (em-dash) and backlinks to an empty row set, the
1336
- * same fail-soft the UI renders. */
1337
- async function loadLinkedTargets(schema, opts) {
1338
- const slugs = [.../* @__PURE__ */ new Set([
1339
- ...require_promptSafety.uniqueRefTargets(schema),
1340
- ...require_promptSafety.uniqueEmbedTargets(schema),
1341
- ...require_promptSafety.uniqueBacklinkSources(schema)
1342
- ])];
1343
- const loaded = {};
1344
- for (const slug of slugs) {
1345
- const target = await loadTarget(slug, opts);
1346
- if (target) loaded[slug] = target;
1157
+ try {
1158
+ return await (0, node_fs_promises.readdir)(dataDir);
1159
+ } catch (err) {
1160
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
1161
+ throw err;
1347
1162
  }
1348
- return loaded;
1349
1163
  }
1350
- function toRefRecords(linked) {
1351
- return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
1164
+ async function validateCollectionRecords(collection, opts = {}) {
1165
+ if (collection.schema.dataSource !== void 0) return [];
1166
+ if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
1167
+ const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
1168
+ const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
1169
+ const issues = [];
1170
+ for (const name of entries.sort()) {
1171
+ if (!name.endsWith(".json") || name.startsWith(".")) continue;
1172
+ if (issues.length >= MAX_ISSUES) break;
1173
+ const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
1174
+ if (issue) issues.push(issue);
1175
+ }
1176
+ return issues;
1352
1177
  }
1353
- /** The matching source rows for one `backlinks` field, projected to the
1354
- * source primaryKey + `display` columns — so getItems on a
1355
- * heavily-referenced record stays a summary, not a dump of the source
1356
- * collection. Missing source ⇒ [] (fail-soft). The rows come from the
1357
- * DERIVED source records (`byId`), so `display`/`filter` on a derived
1358
- * source column (an invoice `total`) works. */
1359
- function projectBacklinks(field, schema, enriched, linked) {
1360
- const source = linked[field.from];
1361
- if (!source) return [];
1362
- const selfId = require_calendarGrid.fieldText(enriched[schema.primaryKey]);
1363
- return require_promptSafety.backlinkRows(field, selfId, Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
1178
+ /** Store-backed twin of the file scan: list every record through the
1179
+ * collection's store and lint it with the same "strict" report-only tier.
1180
+ * A row the store can't even parse is invisible here (the store skips
1181
+ * it), so the read/parse classifications of the file scan don't apply —
1182
+ * schema violations are what this catches. `file` carries the record id
1183
+ * (there is no per-record filename). */
1184
+ async function validateStoreRecords(collection, opts) {
1185
+ let items;
1186
+ try {
1187
+ items = await require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
1188
+ } catch (err) {
1189
+ return [{
1190
+ file: STORE_UNREADABLE,
1191
+ problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
1192
+ }];
1193
+ }
1194
+ const issues = [];
1195
+ for (const item of items) {
1196
+ if (issues.length >= MAX_ISSUES) break;
1197
+ const itemId = require_itemId.fieldText(item[collection.schema.primaryKey]);
1198
+ const problem = validateRecordObject(item, itemId, collection.schema, "strict");
1199
+ if (problem) issues.push({
1200
+ file: itemId,
1201
+ problem
1202
+ });
1203
+ }
1204
+ return issues;
1364
1205
  }
1365
- /** Project the computed (never-stored) field kinds onto one derived
1366
- * record: `toggle` → boolean off its enum, `embed` → the target record
1367
- * (fixed `id` or per-record `idField`), or null when missing,
1368
- * `backlinks` → the matching source rows (see `projectBacklinks`). */
1369
- function projectComputed(schema, enriched, linked) {
1370
- for (const [key, field] of Object.entries(schema.fields)) {
1371
- if (field.type === "toggle" && field.field) enriched[key] = require_calendarGrid.fieldText(enriched[field.field]) === field.onValue;
1372
- if (field.type === "embed" && field.to) {
1373
- const targetId = require_calendarGrid.embedTargetId(field, enriched);
1374
- const target = require_promptSafety.ownProp(linked, field.to);
1375
- enriched[key] = targetId && target && require_promptSafety.ownProp(target.byId, targetId) || null;
1376
- }
1377
- if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
1206
+ async function readRecordText(fullPath, name) {
1207
+ try {
1208
+ if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
1209
+ file: name,
1210
+ problem: "not a regular file (symlink?) — skipped, won't appear"
1211
+ };
1212
+ return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
1213
+ } catch {
1214
+ return {
1215
+ file: name,
1216
+ problem: "could not be read — skipped, won't appear"
1217
+ };
1378
1218
  }
1379
- return enriched;
1380
1219
  }
1381
- /** Resolve every rollup field onto a COPY of the record, BEFORE the
1382
- * formula pass — a `derived` formula may reference rollup values as
1383
- * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
1384
- * machinery as backlinks, collapsed to a number: an unresolvable source
1385
- * is null (a formula reading it fails soft to em-dash); an empty match
1386
- * set is a real 0. Returns `record` unchanged when the schema declares
1387
- * no rollups. */
1388
- function projectRollups(schema, record, linked) {
1389
- let out = record;
1390
- for (const [key, field] of Object.entries(schema.fields)) {
1391
- if (field.type !== "rollup") continue;
1392
- if (out === record) out = { ...record };
1393
- const source = linked[field.from];
1394
- const selfId = require_calendarGrid.fieldText(record[schema.primaryKey]);
1395
- out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
1220
+ /** Classify a single record file: unreadable / unparseable / non-object /
1221
+ * schema violation, or null when it's fine. */
1222
+ async function inspectRecord(fullPath, name, schema) {
1223
+ const read = await readRecordText(fullPath, name);
1224
+ if ("problem" in read) return read;
1225
+ let parsed;
1226
+ try {
1227
+ parsed = JSON.parse(read.raw);
1228
+ } catch (err) {
1229
+ return {
1230
+ file: name,
1231
+ problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
1232
+ };
1396
1233
  }
1397
- return out;
1234
+ if (!require_dist.isRecord(parsed)) return {
1235
+ file: name,
1236
+ problem: "not a JSON object — skipped, won't appear"
1237
+ };
1238
+ const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
1239
+ return problem ? {
1240
+ file: name,
1241
+ problem
1242
+ } : null;
1398
1243
  }
1399
- /** Enrich records with every host-computed field: derived formulas
1400
- * evaluated (cross-collection derefs included), toggles projected,
1401
- * embeds resolved. Loads each linked collection ONCE per call. Input
1402
- * records are not mutated. */
1403
- async function enrichItems(collection, items, opts = {}) {
1404
- const { schema } = collection;
1405
- const linked = await loadLinkedTargets(schema, opts);
1406
- const refRecords = toRefRecords(linked);
1407
- return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
1244
+ /** What a non-string primary key actually is, for the error message. Names the
1245
+ * shape rather than stringifying the value — "[object Object]" tells the reader
1246
+ * nothing about what is wrong. */
1247
+ function describeIdType(value) {
1248
+ if (value === null) return "null";
1249
+ if (value === void 0) return "missing";
1250
+ if (Array.isArray(value)) return "an array";
1251
+ return `a ${typeof value}`;
1408
1252
  }
1409
- //#endregion
1410
- //#region src/collection/server/queryRunner.ts
1411
- async function runCollectionQuery(collection, query, opts = {}) {
1412
- const store = require_store.storeFor(collection, { workspaceRoot: opts.workspaceRoot });
1413
- if (store.query) return store.query(query);
1414
- return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
1253
+ /** First schema problem on an in-memory record (primaryKey↔id mismatch,
1254
+ * then the compiled per-field checks — see `../core/recordZ` for the two
1255
+ * tiers), or null when it's fine. One issue per record keeps the report
1256
+ * short and the fix obvious. Pure + exported so write paths
1257
+ * (manageCollection putItems) can gate on the SAME enforced rules the
1258
+ * post-hoc file scan reports — `itemId` is the id the record is (or
1259
+ * would be) stored under. The default `"enforced"` tier keeps every
1260
+ * write gate on the historical three checks; only pass `"strict"` from
1261
+ * report-only surfaces. */
1262
+ function validateRecordObject(record, itemId, schema, tier = "enforced") {
1263
+ const idValue = record[schema.primaryKey];
1264
+ if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
1265
+ if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
1266
+ return firstRecordProblem(record, schema, tier);
1415
1267
  }
1416
1268
  //#endregion
1417
1269
  //#region src/collection/server/mutate.ts
@@ -1438,7 +1290,7 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
1438
1290
  status: "invalid-params",
1439
1291
  problem: paramProblem
1440
1292
  };
1441
- const store = require_store.storeFor(collection, opts);
1293
+ const store = require_discovery.storeFor(collection, opts);
1442
1294
  if (!store.write) return {
1443
1295
  ok: false,
1444
1296
  status: "write-refused",
@@ -1457,11 +1309,11 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
1457
1309
  };
1458
1310
  const stored = Object.entries(existing).filter(([key]) => {
1459
1311
  const spec = collection.schema.fields[key];
1460
- return !spec || !require_calendarGrid.COMPUTED_TYPES.has(spec.type);
1312
+ return !spec || !require_itemId.COMPUTED_TYPES.has(spec.type);
1461
1313
  });
1462
1314
  const merged = {
1463
1315
  ...Object.fromEntries(stored),
1464
- ...require_store.resolveMutateSet(action.set, params)
1316
+ ...require_discovery.resolveMutateSet(action.set, params)
1465
1317
  };
1466
1318
  const invalid = validateRecordObject(merged, itemId, collection.schema);
1467
1319
  if (invalid) return {
@@ -1519,10 +1371,10 @@ function schemaRelations(schema) {
1519
1371
  * Fail-soft: a missing dir or a dataDir escaping the workspace via
1520
1372
  * symlink counts 0. */
1521
1373
  async function countRecordFiles(dataDir, workspaceRoot) {
1522
- if (!require_store.isContainedInRoot(dataDir, workspaceRoot)) return 0;
1374
+ if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) return 0;
1523
1375
  try {
1524
1376
  const names = (await (0, node_fs_promises.readdir)(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
1525
- return (await Promise.all(names.map((name) => require_store.isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
1377
+ return (await Promise.all(names.map((name) => require_discovery.isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
1526
1378
  } catch {
1527
1379
  return 0;
1528
1380
  }
@@ -1534,10 +1386,10 @@ async function countRecordFiles(dataDir, workspaceRoot) {
1534
1386
  * `countRecordFiles` (an unreadable file / missing engine must not
1535
1387
  * break the whole ontology). */
1536
1388
  async function countRecords(collection, workspaceRoot) {
1537
- if (require_calendarGrid.storageKindFor(collection.schema) !== "file") try {
1538
- return (await require_store.storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
1389
+ if (require_itemId.storageKindFor(collection.schema) !== "file") try {
1390
+ return (await require_discovery.storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
1539
1391
  } catch (err) {
1540
- if (require_store.isBackendUnavailable(err)) return null;
1392
+ if (require_discovery.isBackendUnavailable(err)) return null;
1541
1393
  return 0;
1542
1394
  }
1543
1395
  return countRecordFiles(collection.dataDir, workspaceRoot);
@@ -1558,8 +1410,8 @@ async function toOntologyEntry(collection, workspaceRoot) {
1558
1410
  * discovery's order), each with its outbound relations and a
1559
1411
  * readdir-cheap record count — fine to call on demand. */
1560
1412
  async function buildWorkspaceOntology(opts = {}) {
1561
- const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
1562
- const collections = await require_store.discoverCollections(opts);
1413
+ const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
1414
+ const collections = await require_discovery.discoverCollections(opts);
1563
1415
  return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
1564
1416
  }
1565
1417
  //#endregion
@@ -1579,7 +1431,7 @@ function buildRecordsById(items, primaryKey) {
1579
1431
  * ties pick a different record — and thus a different icon — between
1580
1432
  * reconciles. A stable id sort pins one answer. */
1581
1433
  function sortByPrimaryKey(items, primaryKey) {
1582
- return [...items].sort((left, right) => require_calendarGrid.fieldText(left[primaryKey]).localeCompare(require_calendarGrid.fieldText(right[primaryKey])));
1434
+ return [...items].sort((left, right) => require_itemId.fieldText(left[primaryKey]).localeCompare(require_itemId.fieldText(right[primaryKey])));
1583
1435
  }
1584
1436
  /** Compute the effective launcher icon for `collection`: its static
1585
1437
  * `schema.icon` when it declares no `dynamicIcon`, else the icon
@@ -1593,15 +1445,15 @@ async function computeCollectionIcon(collection, opts = {}) {
1593
1445
  const spec = schema.dynamicIcon;
1594
1446
  if (!spec) return schema.icon;
1595
1447
  try {
1596
- const source = await require_store.loadCollection(spec.source.collection, opts);
1448
+ const source = await require_discovery.loadCollection(spec.source.collection, opts);
1597
1449
  if (!source) return spec.fallback ?? schema.icon;
1598
- const ordered = sortByPrimaryKey(await require_store.storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
1450
+ const ordered = sortByPrimaryKey(await require_discovery.storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
1599
1451
  const orderBy = spec.source.orderBy ?? require_promptSafety.firstDateField(source.schema);
1600
1452
  const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
1601
1453
  const record = require_promptSafety.selectDynamicRecord(ordered, spec.source, orderBy, recordsById);
1602
1454
  return require_promptSafety.resolveIcon(record, spec, schema.icon, recordsById);
1603
1455
  } catch (err) {
1604
- require_store.log.warn("collections", "dynamic icon compute failed, falling back", {
1456
+ require_discovery.log.warn("collections", "dynamic icon compute failed, falling back", {
1605
1457
  slug: collection.slug,
1606
1458
  source: spec.source.collection,
1607
1459
  error: String(err)
@@ -1716,7 +1568,7 @@ function successorId(sourceId, next) {
1716
1568
  function matchesWhen(when, schema, item) {
1717
1569
  if (when) {
1718
1570
  const raw = item[when.field];
1719
- const text = require_calendarGrid.fieldTextOrNull(raw);
1571
+ const text = require_itemId.fieldTextOrNull(raw);
1720
1572
  return text !== null && when.in.includes(text);
1721
1573
  }
1722
1574
  return require_promptSafety.itemIsDone(schema, item);
@@ -1728,10 +1580,10 @@ function matchesWhen(when, schema, item) {
1728
1580
  * Discovery rejects a map that doesn't cover the enum's values, so null
1729
1581
  * here means a record that predates a map/enum edit. */
1730
1582
  function resolveEvery(every, sourceItem) {
1731
- if (!require_calendarGrid.isFieldDrivenEvery(every)) return every;
1583
+ if (!require_itemId.isFieldDrivenEvery(every)) return every;
1732
1584
  const raw = sourceItem[every.fromField];
1733
1585
  if (raw === void 0 || raw === null || raw === "") return null;
1734
- const key = require_calendarGrid.fieldTextOrNull(raw);
1586
+ const key = require_itemId.fieldTextOrNull(raw);
1735
1587
  if (key === null || !Object.hasOwn(every.map, key)) return null;
1736
1588
  return every.map[key] ?? null;
1737
1589
  }
@@ -1763,15 +1615,15 @@ function computeSuccessor(schema, sourceItem, sourceId) {
1763
1615
  * predates a map/enum edit — discovery rejects this statically otherwise). */
1764
1616
  function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
1765
1617
  if (parseCivil(sourceItem[triggerField]) === null) {
1766
- require_store.log.warn("collections", "spawn skipped: source trigger date unparseable", {
1618
+ require_discovery.log.warn("collections", "spawn skipped: source trigger date unparseable", {
1767
1619
  slug,
1768
1620
  sourceId,
1769
1621
  triggerField
1770
1622
  });
1771
1623
  return;
1772
1624
  }
1773
- const fromField = require_calendarGrid.isFieldDrivenEvery(every) ? every.fromField : void 0;
1774
- require_store.log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
1625
+ const fromField = require_itemId.isFieldDrivenEvery(every) ? every.fromField : void 0;
1626
+ require_discovery.log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
1775
1627
  slug,
1776
1628
  sourceId,
1777
1629
  fromField,
@@ -1794,7 +1646,7 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
1794
1646
  return;
1795
1647
  }
1796
1648
  if (matchesWhen(spawn.when, schema, computed.record)) {
1797
- require_store.log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
1649
+ require_discovery.log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
1798
1650
  slug,
1799
1651
  sourceId,
1800
1652
  successorId: computed.id
@@ -1802,28 +1654,28 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
1802
1654
  return;
1803
1655
  }
1804
1656
  try {
1805
- const { write } = require_store.storeFor(collection, ioOpts);
1657
+ const { write } = require_discovery.storeFor(collection, ioOpts);
1806
1658
  if (!write) {
1807
- require_store.log.warn("collections", "spawn skipped: collection store is read-only", {
1659
+ require_discovery.log.warn("collections", "spawn skipped: collection store is read-only", {
1808
1660
  slug,
1809
1661
  sourceId
1810
1662
  });
1811
1663
  return;
1812
1664
  }
1813
1665
  const result = await write(computed.id, computed.record, { refuseOverwrite: true });
1814
- if (result.kind === "ok") require_store.log.info("collections", "spawned successor", {
1666
+ if (result.kind === "ok") require_discovery.log.info("collections", "spawned successor", {
1815
1667
  slug,
1816
1668
  sourceId,
1817
1669
  successorId: computed.id
1818
1670
  });
1819
- else if (result.kind !== "conflict") require_store.log.warn("collections", "spawn write failed", {
1671
+ else if (result.kind !== "conflict") require_discovery.log.warn("collections", "spawn write failed", {
1820
1672
  slug,
1821
1673
  sourceId,
1822
1674
  successorId: computed.id,
1823
1675
  kind: result.kind
1824
1676
  });
1825
1677
  } catch (err) {
1826
- require_store.log.warn("collections", "spawn write threw", {
1678
+ require_discovery.log.warn("collections", "spawn write threw", {
1827
1679
  slug,
1828
1680
  sourceId,
1829
1681
  successorId: computed.id,
@@ -1862,7 +1714,7 @@ function todayStamp() {
1862
1714
  /** Every directory the delete will touch must resolve under the
1863
1715
  * workspace root — guards against a symlinked ancestor escaping it. */
1864
1716
  function deleteTargets(collection, workspaceRoot) {
1865
- const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
1717
+ const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
1866
1718
  return [
1867
1719
  ...staging === null ? [] : [staging],
1868
1720
  collection.skillDir,
@@ -1974,12 +1826,12 @@ function recordLocationLabel(schema) {
1974
1826
  }
1975
1827
  /** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
1976
1828
  async function writeArchive(collection, archiveDir, workspaceRoot) {
1977
- const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
1829
+ const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
1978
1830
  const skillSrc = staging !== null && await pathExists(staging) ? staging : collection.skillDir;
1979
1831
  await (0, node_fs_promises.cp)(skillSrc, node_path.default.join(archiveDir, "skill"), { recursive: true });
1980
1832
  if (await pathExists(collection.dataDir)) await (0, node_fs_promises.cp)(collection.dataDir, node_path.default.join(archiveDir, "records"), { recursive: true });
1981
1833
  if (collection.storageFile !== void 0 && await pathExists(collection.storageFile)) {
1982
- const checkpointed = await require_store.checkpointSqliteDatabase(collection.storageFile);
1834
+ const checkpointed = await require_discovery.checkpointSqliteDatabase(collection.storageFile);
1983
1835
  await (0, node_fs_promises.cp)(collection.storageFile, node_path.default.join(archiveDir, node_path.default.basename(collection.storageFile)));
1984
1836
  if (!checkpointed) for (const suffix of [
1985
1837
  "-wal",
@@ -1996,7 +1848,7 @@ async function writeArchive(collection, archiveDir, workspaceRoot) {
1996
1848
  * is a no-op; the now-empty data parent (`data/<slug>/` after its
1997
1849
  * `items/` is gone) is swept too, but only when empty. */
1998
1850
  async function removeLocations(collection, workspaceRoot) {
1999
- const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
1851
+ const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
2000
1852
  if (staging !== null) await (0, node_fs_promises.rm)(staging, {
2001
1853
  recursive: true,
2002
1854
  force: true
@@ -2020,24 +1872,24 @@ async function removeLocations(collection, workspaceRoot) {
2020
1872
  }
2021
1873
  async function deleteCollection(collection, opts = {}) {
2022
1874
  const { slug } = collection;
2023
- const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
1875
+ const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
2024
1876
  if (collection.source === "user") return {
2025
1877
  kind: "user-scope",
2026
1878
  slug
2027
1879
  };
2028
- if (require_store.isPresetSlug(slug)) return {
1880
+ if (require_discovery.isPresetSlug(slug)) return {
2029
1881
  kind: "preset",
2030
1882
  slug
2031
1883
  };
2032
1884
  if (collection.schema.storage?.type === "firestore") {
2033
- require_store.log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
1885
+ require_discovery.log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
2034
1886
  return {
2035
1887
  kind: "unsupported-backend",
2036
1888
  slug
2037
1889
  };
2038
1890
  }
2039
1891
  if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
2040
- require_store.log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
1892
+ require_discovery.log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
2041
1893
  slug,
2042
1894
  dataDir: collection.dataDir
2043
1895
  });
@@ -2046,19 +1898,19 @@ async function deleteCollection(collection, opts = {}) {
2046
1898
  slug
2047
1899
  };
2048
1900
  }
2049
- if (deleteTargets(collection, workspaceRoot).some((target) => !require_store.isContainedInRoot(target, workspaceRoot))) {
2050
- require_store.log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
1901
+ if (deleteTargets(collection, workspaceRoot).some((target) => !require_discovery.isContainedInRoot(target, workspaceRoot))) {
1902
+ require_discovery.log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
2051
1903
  return {
2052
1904
  kind: "path-escape",
2053
1905
  slug
2054
1906
  };
2055
1907
  }
2056
- const archiveRel = node_path.default.join(require_store.archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
1908
+ const archiveRel = node_path.default.join(require_discovery.archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
2057
1909
  const archiveDir$1 = node_path.default.join(workspaceRoot, archiveRel);
2058
1910
  await (0, node_fs_promises.mkdir)(archiveDir$1, { recursive: true });
2059
1911
  await writeArchive(collection, archiveDir$1, workspaceRoot);
2060
1912
  await removeLocations(collection, workspaceRoot);
2061
- require_store.log.info("collections", "collection deleted + archived", {
1913
+ require_discovery.log.info("collections", "collection deleted + archived", {
2062
1914
  slug,
2063
1915
  archive: archiveRel
2064
1916
  });
@@ -2088,7 +1940,7 @@ async function fileExists(target) {
2088
1940
  * deletes agree on both layouts. */
2089
1941
  async function canonicalBase(collection, workspaceRoot, safeSlug) {
2090
1942
  if (collection.source !== "project") return collection.skillDir;
2091
- const staging = require_store.stagingSkillDir(workspaceRoot, safeSlug);
1943
+ const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
2092
1944
  if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) return staging;
2093
1945
  return collection.skillDir;
2094
1946
  }
@@ -2098,11 +1950,11 @@ async function canonicalBase(collection, workspaceRoot, safeSlug) {
2098
1950
  * project collection (no staging mirror) doesn't have an empty staging tree
2099
1951
  * materialised by a side effect of the delete. */
2100
1952
  async function schemaWriteTargets(collection, workspaceRoot, safeSlug) {
2101
- const active = node_path.default.join(collection.skillDir, require_store.SCHEMA_FILE);
1953
+ const active = node_path.default.join(collection.skillDir, require_discovery.SCHEMA_FILE);
2102
1954
  if (collection.source !== "project") return [active];
2103
- const staging = require_store.stagingSkillDir(workspaceRoot, safeSlug);
1955
+ const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
2104
1956
  const targets = [];
2105
- if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging, require_store.SCHEMA_FILE));
1957
+ if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging, require_discovery.SCHEMA_FILE));
2106
1958
  targets.push(active);
2107
1959
  return targets;
2108
1960
  }
@@ -2121,7 +1973,7 @@ async function unlinkIfPresent(target) {
2121
1973
  * preserved verbatim. */
2122
1974
  async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug) {
2123
1975
  const base = await canonicalBase(collection, workspaceRoot, safeSlug);
2124
- const canonical = node_path.default.join(base, require_store.SCHEMA_FILE);
1976
+ const canonical = node_path.default.join(base, require_discovery.SCHEMA_FILE);
2125
1977
  const parsed = JSON.parse(await (0, node_fs_promises.readFile)(canonical, "utf-8"));
2126
1978
  const next = require_dist.isRecord(parsed) && require_dist.isUnknownArray(parsed.views) ? {
2127
1979
  ...parsed,
@@ -2135,8 +1987,8 @@ async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug
2135
1987
  * refused (read-only / re-seeded on boot), consistent with `deleteCollection`. */
2136
1988
  async function deleteCustomView(collection, viewId, opts = {}) {
2137
1989
  if (collection.source === "user") return { kind: "user-scope" };
2138
- if (require_store.isPresetSlug(collection.slug)) return { kind: "preset" };
2139
- const safeSlug = require_store.safeSlugName(collection.slug);
1990
+ if (require_discovery.isPresetSlug(collection.slug)) return { kind: "preset" };
1991
+ const safeSlug = require_discovery.safeSlugName(collection.slug);
2140
1992
  if (safeSlug === null) return {
2141
1993
  kind: "unsafe-path",
2142
1994
  viewId
@@ -2147,8 +1999,8 @@ async function deleteCustomView(collection, viewId, opts = {}) {
2147
1999
  kind: "not-found",
2148
2000
  viewId
2149
2001
  };
2150
- const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
2151
- const htmlPath = require_store.resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
2002
+ const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
2003
+ const htmlPath = require_discovery.resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
2152
2004
  if (htmlPath === null) return {
2153
2005
  kind: "unsafe-path",
2154
2006
  viewId
@@ -2375,7 +2227,7 @@ var SCHEMA_DOCS_PROBE_SLUG = "_";
2375
2227
  /** Resolve the workspace root the same way every collections call does:
2376
2228
  * the injected override (tests) or the configured collection host. */
2377
2229
  function resolveBase(deps) {
2378
- return deps.workspaceRoot ?? require_store.getWorkspaceRoot();
2230
+ return deps.workspaceRoot ?? require_discovery.getWorkspaceRoot();
2379
2231
  }
2380
2232
  /** Where a collection skill is authored in THIS root, and therefore both which
2381
2233
  * authoring guide `schemaDocs` serves and where `getSchema` / `putSchema`
@@ -2389,7 +2241,7 @@ function resolveBase(deps) {
2389
2241
  * from there, so the tool would silently contradict its own documentation.
2390
2242
  * Staged requires BOTH to agree; anything else is direct. */
2391
2243
  function authoringTarget(deps, slug) {
2392
- const stagingDir = deps.stagedSkillAuthoring === false ? null : require_store.stagingSkillDir(resolveBase(deps), slug);
2244
+ const stagingDir = deps.stagedSkillAuthoring === false ? null : require_discovery.stagingSkillDir(resolveBase(deps), slug);
2393
2245
  return {
2394
2246
  variant: stagingDir === null ? "direct" : "staged",
2395
2247
  stagingDir
@@ -2432,7 +2284,7 @@ async function recordIssuesWarning(collection, deps) {
2432
2284
  return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
2433
2285
  }
2434
2286
  async function loadRequestedItems(collection, ids, deps) {
2435
- const store = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2287
+ const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2436
2288
  if (!ids) return {
2437
2289
  items: await store.list(),
2438
2290
  missing: []
@@ -2441,7 +2293,7 @@ async function loadRequestedItems(collection, ids, deps) {
2441
2293
  const missing = [];
2442
2294
  for (const recordId of ids) {
2443
2295
  const item = await store.read(recordId).catch((err) => {
2444
- if (require_store.isBackendUnavailable(err)) throw err;
2296
+ if (require_discovery.isBackendUnavailable(err)) throw err;
2445
2297
  return null;
2446
2298
  });
2447
2299
  if (item) items.push(item);
@@ -2472,7 +2324,7 @@ async function handleGetItems(collection, args, deps) {
2472
2324
  function computedKeyProblem(record, schema) {
2473
2325
  for (const key of Object.keys(record)) {
2474
2326
  const spec = schema.fields[key];
2475
- if (!spec || !require_calendarGrid.COMPUTED_TYPES.has(spec.type)) continue;
2327
+ if (!spec || !require_itemId.COMPUTED_TYPES.has(spec.type)) continue;
2476
2328
  if (spec.type === "toggle" && spec.field) return `'${key}' is a toggle projection — write the enum field '${spec.field}' instead`;
2477
2329
  return `'${key}' is ${{
2478
2330
  derived: "derived",
@@ -2506,13 +2358,13 @@ async function mergeWithExisting(collection, store, record, itemId) {
2506
2358
  try {
2507
2359
  existing = await store.read(itemId);
2508
2360
  } catch (err) {
2509
- if (require_store.isBackendUnavailable(err)) throw err;
2361
+ if (require_discovery.isBackendUnavailable(err)) throw err;
2510
2362
  return `'${itemId}' has a malformed stored file — mode "merge" needs to read it; fix the file (Read → correct → Write) or replace it whole with "upsert"`;
2511
2363
  }
2512
2364
  if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
2513
2365
  const stored = Object.entries(existing).filter(([key]) => {
2514
2366
  const spec = collection.schema.fields[key];
2515
- return !spec || !require_calendarGrid.COMPUTED_TYPES.has(spec.type);
2367
+ return !spec || !require_itemId.COMPUTED_TYPES.has(spec.type);
2516
2368
  });
2517
2369
  return {
2518
2370
  ...Object.fromEntries(stored),
@@ -2521,7 +2373,7 @@ async function mergeWithExisting(collection, store, record, itemId) {
2521
2373
  }
2522
2374
  async function putOneItem(collection, store, write, record, mode, deps) {
2523
2375
  const { schema } = collection;
2524
- const itemId = require_store.resolveCreateItemId(schema, record);
2376
+ const itemId = require_discovery.resolveCreateItemId(schema, record);
2525
2377
  const reject = (about, problem) => ({ rejected: {
2526
2378
  id: require_promptSafety.defangForPrompt(about),
2527
2379
  problem: require_promptSafety.defangForPrompt(problem)
@@ -2552,7 +2404,7 @@ async function putOneItem(collection, store, write, record, mode, deps) {
2552
2404
  * `runCollectionQuery`, shared with the desktop custom view's `/query`
2553
2405
  * route so the two surfaces can never drift. */
2554
2406
  async function handleQueryItems(collection, queryArg, deps) {
2555
- const parsed = require_store.CollectionQueryZ.safeParse(queryArg);
2407
+ const parsed = require_discovery.CollectionQueryZ.safeParse(queryArg);
2556
2408
  if (!parsed.success) return `manageCollection: \`query\` rejected — fix and retry:\n${parsed.error.issues.slice(0, 20).map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${require_promptSafety.defangForPrompt(issue.message)}`).join("\n")}`;
2557
2409
  const rows = await runCollectionQuery(collection, parsed.data, { workspaceRoot: deps.workspaceRoot });
2558
2410
  return JSON.stringify({
@@ -2562,9 +2414,9 @@ async function handleQueryItems(collection, queryArg, deps) {
2562
2414
  });
2563
2415
  }
2564
2416
  async function handlePutItems(collection, args, deps) {
2565
- const store = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2417
+ const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2566
2418
  const { write } = store;
2567
- if (!write) return `manageCollection: ${require_store.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
2419
+ if (!write) return `manageCollection: ${require_discovery.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
2568
2420
  const written = [];
2569
2421
  const rejected = [];
2570
2422
  for (const record of args.items) {
@@ -2583,8 +2435,8 @@ async function handlePutItems(collection, args, deps) {
2583
2435
  * absent `delete` IS the refusal), and the same per-id result shape so a
2584
2436
  * partially-bad batch reports per id instead of failing whole. */
2585
2437
  async function handleDeleteItems(collection, ids, deps) {
2586
- const { delete: removeItem } = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2587
- if (!removeItem) return `manageCollection: ${require_store.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
2438
+ const { delete: removeItem } = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2439
+ if (!removeItem) return `manageCollection: ${require_discovery.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
2588
2440
  const deleted = [];
2589
2441
  const rejected = [];
2590
2442
  for (const itemId of ids) {
@@ -2653,32 +2505,6 @@ async function handleGetOntology(deps) {
2653
2505
  collections
2654
2506
  });
2655
2507
  }
2656
- /** Publish this repository's `app.json` + shared schemas to its Firestore app.
2657
- *
2658
- * Named as a whole-app action because it IS one: publish takes the repository
2659
- * as its unit (one roster, one public config, every shared collection), so it
2660
- * carries no `slug` and refusing one is not a limitation to work around.
2661
- *
2662
- * The reply is prose rather than a status code on purpose. This is the one
2663
- * operation in the collection surface that changes what every member sees the
2664
- * moment it lands, and the two things the caller has to relay — what will
2665
- * break, and that `confirm` is how it proceeds anyway — are sentences, not
2666
- * fields. */
2667
- async function handlePublishApp(deps, confirm) {
2668
- const result = await publishApp({
2669
- ...deps,
2670
- confirm
2671
- });
2672
- if (!result.ok) {
2673
- const bullets = result.problems.map((problem) => `- ${problem}`).join("\n");
2674
- return `${result.partial ? "publish FAILED PART-WAY — some documents are already live:" : "publish refused — nothing was written:"}\n${bullets}`;
2675
- }
2676
- const dirtyNote = result.dirty ? " (WORKING TREE DIRTY — the commit does not describe what was published)" : "";
2677
- const stamp = result.commit ? `commit ${result.commit.slice(0, 12)}${dirtyNote}` : "no commit (not a git repository, or no HEAD)";
2678
- const brokenCount = result.recordIssuesCapped ? `at least ${result.recordIssues}` : `${result.recordIssues}`;
2679
- const forced = result.recordIssues > 0 ? ` Published over ${brokenCount} record(s) that do not satisfy the new schema — repair them now; members are reading them.` : "";
2680
- return `${result.created ? "Created" : "Updated"} app '${result.aid}' and published ${result.cids.length} collection(s): ${result.cids.join(", ")}. Signed ${stamp}. The previous app document is kept in \`previousPublished\` for rollback.${forced}`;
2681
- }
2682
2508
  /** Return the collection-authoring reference (`collection-skills.md`),
2683
2509
  * rendered by `renderSchemaDocs` — the full doc overflows the agent's
2684
2510
  * per-result limit, so the default reply is the core guide + a table of
@@ -2699,7 +2525,7 @@ async function handleSchemaDocs(deps, topic) {
2699
2525
  * fallback for user-scope skills that have no staging copy. Raw text —
2700
2526
  * not the parsed schema — so the agent edits the true on-disk source. */
2701
2527
  async function handleGetSchema(slug, deps) {
2702
- const collection = await require_store.loadCollection(slug, deps);
2528
+ const collection = await require_discovery.loadCollection(slug, deps);
2703
2529
  if (!collection) return unknownCollection(slug);
2704
2530
  const { stagingDir } = authoringTarget(deps, collection.slug);
2705
2531
  const candidates = [...stagingDir === null ? [] : [node_path.default.join(stagingDir, SCHEMA_FILE)], node_path.default.join(collection.skillDir, SCHEMA_FILE)];
@@ -2761,11 +2587,11 @@ async function writeAndMirrorSchema(slug, skillDir, schema, deps) {
2761
2587
  * gate doesn't apply. Returns a one-line reason, or null when the schema
2762
2588
  * would be accepted. */
2763
2589
  function schemaDiscoveryGate(schema, base) {
2764
- const primaryField = require_store.resolvePrimaryField(schema.fields, schema.primaryKey);
2590
+ const primaryField = require_discovery.resolvePrimaryField(schema.fields, schema.primaryKey);
2765
2591
  if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
2766
2592
  if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
2767
- if (schema.dataPath !== void 0 && require_store.resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
2768
- if (schema.dataSource !== void 0 && require_store.resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
2593
+ if (schema.dataPath !== void 0 && require_discovery.resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
2594
+ if (schema.dataSource !== void 0 && require_discovery.resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
2769
2595
  return null;
2770
2596
  }
2771
2597
  /** Validate a schema against CollectionSchemaZ and, on success, persist it.
@@ -2773,11 +2599,11 @@ function schemaDiscoveryGate(schema, base) {
2773
2599
  * the root's authoring dir (the normal create flow), not through here. */
2774
2600
  async function handlePutSchema(slug, schemaArg, deps) {
2775
2601
  if (!schemaArg || typeof schemaArg !== "object" || Array.isArray(schemaArg)) return "manageCollection: `schema` is required for putSchema — the full collection schema object.";
2776
- const collection = await require_store.loadCollection(slug, deps);
2602
+ const collection = await require_discovery.loadCollection(slug, deps);
2777
2603
  if (!collection) return `manageCollection: unknown collection '${require_promptSafety.defangForPrompt(slug)}' — create it by writing SKILL.md + ${SCHEMA_FILE} under ${authoringDirLabel(deps, require_promptSafety.defangForPrompt(slug))}, then edit it here.`;
2778
2604
  const refusal = schemaEditRefusal(collection, slug, deps);
2779
2605
  if (refusal) return refusal;
2780
- const parsed = require_store.CollectionSchemaZ.safeParse(schemaArg);
2606
+ const parsed = require_discovery.CollectionSchemaZ.safeParse(schemaArg);
2781
2607
  if (!parsed.success) return formatSchemaIssues(parsed.error.issues);
2782
2608
  const gate = schemaDiscoveryGate(parsed.data, resolveBase(deps));
2783
2609
  if (gate) return `manageCollection: schema rejected — ${gate} (call schemaDocs for the field reference). It passes basic validation but discovery would skip it, hiding the collection.`;
@@ -2787,7 +2613,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
2787
2613
  written: true
2788
2614
  });
2789
2615
  }
2790
- var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. `publishApp` publishes the whole repository — its `app.json` (member roster, public read/submit configuration) and every shared collection's schema — to the app's Firestore. It is the ONE operation here that changes what every member sees the moment it runs, and it cannot be undone by reverting a commit: publishing again is the only undo (the previous app document is kept as `previousPublished`). Call it when the user asks to publish, invite, or open an app; never as a follow-up to an edit the user did not ask you to ship. Its refusals are the product, not an obstacle — relay them verbatim, and do not work around one by rewriting `app.json` until the user has said what they actually want. If it reports live records the new schemas would break, show the user that list and ask before re-running with `confirm`. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
2616
+ var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
2791
2617
  /** Validate getItems' optional `ids`/`fields` args, then delegate. */
2792
2618
  async function dispatchGetItems(collection, args, deps) {
2793
2619
  const ids = optionalStringArray(args.ids, "ids");
@@ -2824,7 +2650,7 @@ async function manageCollectionHandler(deps, args) {
2824
2650
  try {
2825
2651
  return await dispatchManageCollection(deps, args);
2826
2652
  } catch (err) {
2827
- if (require_store.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
2653
+ if (require_discovery.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
2828
2654
  throw err;
2829
2655
  }
2830
2656
  }
@@ -2832,19 +2658,18 @@ async function dispatchManageCollection(deps, args) {
2832
2658
  const action = typeof args.action === "string" ? args.action : "";
2833
2659
  if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
2834
2660
  if (action === "getOntology") return handleGetOntology(deps);
2835
- if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
2836
2661
  const slug = typeof args.slug === "string" ? args.slug.trim() : "";
2837
2662
  if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
2838
2663
  if (action === "getSchema") return handleGetSchema(slug, deps);
2839
2664
  if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
2840
- if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", \"putSchema\", or \"publishApp\".";
2841
- const collection = await require_store.loadCollection(slug, deps);
2665
+ if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
2666
+ const collection = await require_discovery.loadCollection(slug, deps);
2842
2667
  if (!collection) return unknownCollection(slug);
2843
2668
  return dispatchRecordAction(action, collection, args, deps);
2844
2669
  }
2845
2670
  var MANAGE_COLLECTION_DEFINITION = {
2846
2671
  name: "manageCollection",
2847
- description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. publishApp publishes the repository's app.json + shared schemas to Firestore, where every member sees them immediately -- it refuses declarations that would be silently permissive or silently deny everyone, and refuses to publish over records the new schemas would break unless `confirm` is set. Prefer it over raw file I/O on collections.",
2672
+ description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
2848
2673
  inputSchema: {
2849
2674
  type: "object",
2850
2675
  properties: {
@@ -2858,8 +2683,7 @@ var MANAGE_COLLECTION_DEFINITION = {
2858
2683
  "getOntology",
2859
2684
  "schemaDocs",
2860
2685
  "getSchema",
2861
- "putSchema",
2862
- "publishApp"
2686
+ "putSchema"
2863
2687
  ],
2864
2688
  description: "What to do."
2865
2689
  },
@@ -2895,10 +2719,6 @@ var MANAGE_COLLECTION_DEFINITION = {
2895
2719
  type: "object",
2896
2720
  description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
2897
2721
  },
2898
- confirm: {
2899
- type: "boolean",
2900
- description: "publishApp: publish even though existing records fail the schemas being published. Omit it first — the refusal lists what would break, and that list is the thing to show the user before asking."
2901
- },
2902
2722
  topic: {
2903
2723
  type: "string",
2904
2724
  description: "schemaDocs: fetch one section of the reference by heading (case-insensitive substring — e.g. \"field types\", \"kanban\", \"calendar\", \"dataSource\"). Omit for the core authoring guide plus a table of contents of every section; \"all\" returns the full document (large — it can exceed your tool-result limit)."
@@ -2932,6 +2752,12 @@ Object.defineProperty(exports, "APP_ROLES", {
2932
2752
  return APP_ROLES;
2933
2753
  }
2934
2754
  });
2755
+ Object.defineProperty(exports, "APP_SLUGS_COLLECTION", {
2756
+ enumerable: true,
2757
+ get: function() {
2758
+ return APP_SLUGS_COLLECTION;
2759
+ }
2760
+ });
2935
2761
  Object.defineProperty(exports, "AuthoredAppZ", {
2936
2762
  enumerable: true,
2937
2763
  get: function() {
@@ -2992,6 +2818,18 @@ Object.defineProperty(exports, "appSchemasPath", {
2992
2818
  return appSchemasPath;
2993
2819
  }
2994
2820
  });
2821
+ Object.defineProperty(exports, "appSlugDoc", {
2822
+ enumerable: true,
2823
+ get: function() {
2824
+ return appSlugDoc;
2825
+ }
2826
+ });
2827
+ Object.defineProperty(exports, "appStagingPath", {
2828
+ enumerable: true,
2829
+ get: function() {
2830
+ return appStagingPath;
2831
+ }
2832
+ });
2995
2833
  Object.defineProperty(exports, "applyMutateAction", {
2996
2834
  enumerable: true,
2997
2835
  get: function() {
@@ -3118,16 +2956,28 @@ Object.defineProperty(exports, "projectApp", {
3118
2956
  return projectApp;
3119
2957
  }
3120
2958
  });
3121
- Object.defineProperty(exports, "promptPathsFor", {
2959
+ Object.defineProperty(exports, "projectDeploy", {
3122
2960
  enumerable: true,
3123
2961
  get: function() {
3124
- return promptPathsFor;
2962
+ return projectDeploy;
2963
+ }
2964
+ });
2965
+ Object.defineProperty(exports, "projectPublish", {
2966
+ enumerable: true,
2967
+ get: function() {
2968
+ return projectPublish;
2969
+ }
2970
+ });
2971
+ Object.defineProperty(exports, "promoteSchema", {
2972
+ enumerable: true,
2973
+ get: function() {
2974
+ return promoteSchema;
3125
2975
  }
3126
2976
  });
3127
- Object.defineProperty(exports, "publishApp", {
2977
+ Object.defineProperty(exports, "promptPathsFor", {
3128
2978
  enumerable: true,
3129
2979
  get: function() {
3130
- return publishApp;
2980
+ return promptPathsFor;
3131
2981
  }
3132
2982
  });
3133
2983
  Object.defineProperty(exports, "publishProblems", {
@@ -3184,6 +3034,12 @@ Object.defineProperty(exports, "schemaRelations", {
3184
3034
  return schemaRelations;
3185
3035
  }
3186
3036
  });
3037
+ Object.defineProperty(exports, "stagedRuleConfig", {
3038
+ enumerable: true,
3039
+ get: function() {
3040
+ return stagedRuleConfig;
3041
+ }
3042
+ });
3187
3043
  Object.defineProperty(exports, "successorId", {
3188
3044
  enumerable: true,
3189
3045
  get: function() {
@@ -3203,4 +3059,4 @@ Object.defineProperty(exports, "validateRecordObject", {
3203
3059
  }
3204
3060
  });
3205
3061
 
3206
- //# sourceMappingURL=server-CWZyg8fn.cjs.map
3062
+ //# sourceMappingURL=server-CgkkWWBd.cjs.map