@mulmoclaude/core 0.1.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 (122) hide show
  1. package/assets/helps/billing-clients-worklog.md +215 -0
  2. package/assets/helps/billing-invoice.md +458 -0
  3. package/assets/helps/business.md +104 -0
  4. package/assets/helps/collection-skills.md +810 -0
  5. package/assets/helps/custom-view.md +433 -0
  6. package/assets/helps/feeds.md +114 -0
  7. package/assets/helps/gemini.md +57 -0
  8. package/assets/helps/github.md +23 -0
  9. package/assets/helps/guide.md +61 -0
  10. package/assets/helps/index.md +89 -0
  11. package/assets/helps/lessons-collection.md +400 -0
  12. package/assets/helps/mulmoscript.md +249 -0
  13. package/assets/helps/portfolio-tracker.md +211 -0
  14. package/assets/helps/presentation-deck.md +828 -0
  15. package/assets/helps/presenthtml.md +89 -0
  16. package/assets/helps/sandbox.md +97 -0
  17. package/assets/helps/spreadsheet.md +43 -0
  18. package/assets/helps/storyteller.md +101 -0
  19. package/assets/helps/telegram.md +136 -0
  20. package/assets/helps/todo-collection.md +140 -0
  21. package/assets/helps/vocabulary.md +109 -0
  22. package/assets/helps/wiki.md +168 -0
  23. package/assets/skills-preset/mc-cooking-coach/SKILL.md +217 -0
  24. package/assets/skills-preset/mc-library/SKILL.md +188 -0
  25. package/assets/skills-preset/mc-manage-automations/SKILL.md +119 -0
  26. package/assets/skills-preset/mc-manage-skills/SKILL.md +141 -0
  27. package/assets/skills-preset/mc-wiki-deep-lint/SKILL.md +108 -0
  28. package/assets/skills-preset/mc-wiki-health-check/SKILL.md +61 -0
  29. package/assets/skills-preset/mc-wiki-ingest/SKILL.md +182 -0
  30. package/assets/skills-preset/mc-wiki-promote/SKILL.md +175 -0
  31. package/assets/skills-preset/mc-zenn/SKILL.md +136 -0
  32. package/dist/chunk-CKQMccvm.cjs +28 -0
  33. package/dist/collection/core/actionVisible.d.ts +34 -0
  34. package/dist/collection/core/calendarGrid.d.ts +120 -0
  35. package/dist/collection/core/deriveAll.d.ts +38 -0
  36. package/dist/collection/core/derivedFormula.d.ts +18 -0
  37. package/dist/collection/core/draft.d.ts +18 -0
  38. package/dist/collection/core/enumColors.d.ts +33 -0
  39. package/dist/collection/core/errorMessage.d.ts +4 -0
  40. package/dist/collection/core/itemLabel.d.ts +12 -0
  41. package/dist/collection/core/presentCollection.d.ts +13 -0
  42. package/dist/collection/core/promptSafety.d.ts +1 -0
  43. package/dist/collection/core/schema.d.ts +355 -0
  44. package/dist/collection/core/shortHexId.d.ts +8 -0
  45. package/dist/collection/core/sortItems.d.ts +29 -0
  46. package/dist/collection/core/uiTypes.d.ts +106 -0
  47. package/dist/collection/index.cjs +793 -0
  48. package/dist/collection/index.cjs.map +1 -0
  49. package/dist/collection/index.d.ts +14 -0
  50. package/dist/collection/index.js +740 -0
  51. package/dist/collection/index.js.map +1 -0
  52. package/dist/collection/paths.cjs +44 -0
  53. package/dist/collection/paths.cjs.map +1 -0
  54. package/dist/collection/paths.js +41 -0
  55. package/dist/collection/paths.js.map +1 -0
  56. package/dist/collection/server/atomic.d.ts +1 -0
  57. package/dist/collection/server/delete.d.ts +38 -0
  58. package/dist/collection/server/derive.d.ts +8 -0
  59. package/dist/collection/server/discoveredCollection.d.ts +18 -0
  60. package/dist/collection/server/discovery.d.ts +227 -0
  61. package/dist/collection/server/host.d.ts +77 -0
  62. package/dist/collection/server/index.cjs +1721 -0
  63. package/dist/collection/server/index.cjs.map +1 -0
  64. package/dist/collection/server/index.d.ts +11 -0
  65. package/dist/collection/server/index.js +1671 -0
  66. package/dist/collection/server/index.js.map +1 -0
  67. package/dist/collection/server/io.d.ts +114 -0
  68. package/dist/collection/server/paths.d.ts +52 -0
  69. package/dist/collection/server/spawn.d.ts +55 -0
  70. package/dist/collection/server/templatePath.d.ts +25 -0
  71. package/dist/collection/server/util.d.ts +3 -0
  72. package/dist/collection/server/validate.d.ts +19 -0
  73. package/dist/collection/server/views.d.ts +20 -0
  74. package/dist/deriveAll-C15OpM3K.cjs +399 -0
  75. package/dist/deriveAll-C15OpM3K.cjs.map +1 -0
  76. package/dist/deriveAll-C6BYnpBL.js +364 -0
  77. package/dist/deriveAll-C6BYnpBL.js.map +1 -0
  78. package/dist/file-change/index.cjs +72 -0
  79. package/dist/file-change/index.cjs.map +1 -0
  80. package/dist/file-change/index.d.ts +43 -0
  81. package/dist/file-change/index.js +66 -0
  82. package/dist/file-change/index.js.map +1 -0
  83. package/dist/notifier/engine.d.ts +72 -0
  84. package/dist/notifier/index.cjs +484 -0
  85. package/dist/notifier/index.cjs.map +1 -0
  86. package/dist/notifier/index.d.ts +3 -0
  87. package/dist/notifier/index.js +464 -0
  88. package/dist/notifier/index.js.map +1 -0
  89. package/dist/notifier/store.d.ts +18 -0
  90. package/dist/notifier/types.d.ts +118 -0
  91. package/dist/notifier/validate.d.ts +17 -0
  92. package/dist/scheduler/adapter.d.ts +48 -0
  93. package/dist/scheduler/index.cjs +352 -0
  94. package/dist/scheduler/index.cjs.map +1 -0
  95. package/dist/scheduler/index.d.ts +2 -0
  96. package/dist/scheduler/index.js +343 -0
  97. package/dist/scheduler/index.js.map +1 -0
  98. package/dist/scheduler/task-manager.d.ts +51 -0
  99. package/dist/whisper/client.cjs +241 -0
  100. package/dist/whisper/client.cjs.map +1 -0
  101. package/dist/whisper/client.d.ts +35 -0
  102. package/dist/whisper/client.js +239 -0
  103. package/dist/whisper/client.js.map +1 -0
  104. package/dist/whisper/ffmpeg.d.ts +6 -0
  105. package/dist/whisper/index.cjs +433 -0
  106. package/dist/whisper/index.cjs.map +1 -0
  107. package/dist/whisper/index.d.ts +5 -0
  108. package/dist/whisper/index.js +425 -0
  109. package/dist/whisper/index.js.map +1 -0
  110. package/dist/whisper/internal.d.ts +11 -0
  111. package/dist/whisper/models.d.ts +49 -0
  112. package/dist/whisper/sidecar.d.ts +8 -0
  113. package/dist/whisper/whisper.d.ts +28 -0
  114. package/dist/workspace-setup/assets.d.ts +10 -0
  115. package/dist/workspace-setup/index.d.ts +3 -0
  116. package/dist/workspace-setup/index.js +556 -0
  117. package/dist/workspace-setup/index.js.map +1 -0
  118. package/dist/workspace-setup/slug.d.ts +6 -0
  119. package/dist/workspace-setup/slug.js +13 -0
  120. package/dist/workspace-setup/slug.js.map +1 -0
  121. package/dist/workspace-setup/sync.d.ts +94 -0
  122. package/package.json +95 -0
@@ -0,0 +1,810 @@
1
+ # Collection skills — build a data app from a schema
2
+
3
+ A **collection skill** is a skill directory that ships a `schema.json` next to
4
+ its `SKILL.md`. The schema declares an entire data-driven app — its data model,
5
+ cross-record relations, rendered UI, computed fields, and per-record action
6
+ buttons — in one small JSON file. You author the schema, you write the records
7
+ (one JSON file each), and you are the runtime for any behaviour the schema
8
+ can't express declaratively. The host contains **zero** knowledge of any
9
+ specific collection: it just reads the DSL and renders a table / calendar /
10
+ form / detail view, and serves a REST surface. No database, no migration tool, no ORM — a
11
+ `schema.json` plus a folder of `<id>.json` records **is** the app.
12
+
13
+ This is the project philosophy made concrete: _the workspace is the database;
14
+ files are the source of truth; you are the intelligent interface._
15
+
16
+ ## Anatomy of a collection skill
17
+
18
+ You **author** the skill under `data/skills/<slug>/` (a plain, writable data
19
+ dir). A host-side hook then **mirrors** the files into `.claude/skills/<slug>/`,
20
+ which is where the host actually discovers and renders the collection from:
21
+
22
+ ```
23
+ data/skills/<slug>/ ← YOU write here (Write / Edit)
24
+ SKILL.md ← instructions you read later (how to CRUD the records)
25
+ schema.json ← the DSL: data model + relations + UI + actions
26
+ templates/*.md ← natural-language bodies for actions (only if it has actions)
27
+
28
+ │ host's skill-bridge hook mirrors these three file kinds
29
+
30
+ .claude/skills/<slug>/ ← host reads here (do NOT write here directly)
31
+ SKILL.md · schema.json · templates/*.md
32
+
33
+ data/<name>/items/ ← the records (separate from the skill dir)
34
+ <id>.json ← one record per file (you write; host reads + renders)
35
+ ```
36
+
37
+ - **Author under `data/skills/<slug>/`, NEVER `.claude/skills/<slug>/`
38
+ directly.** Claude Code gates writes into `.claude/` (it's the agent's own
39
+ config surface) and the host GUI can't answer that prompt, so a direct write
40
+ hangs/fails. Writing under `data/skills/` has no such gate; the bridge hook
41
+ copies `SKILL.md`, `schema.json`, and `templates/*.md` across for you and
42
+ triggers a re-scan, so the collection appears at `/collections/<slug>`
43
+ without a restart. (Other files you drop in `data/skills/<slug>/` — a README,
44
+ scratch notes — stay put and are NOT mirrored.)
45
+ - **To CHANGE an existing collection's schema, use `manageCollection` — not raw
46
+ file edits.** Call `schemaDocs` for this very reference, `getSchema` to read
47
+ the current `schema.json` (you don't need to know its path), then `putSchema`
48
+ to write it back. `putSchema` validates the whole schema against the same rules
49
+ discovery enforces and reports the exact problem, where a hand-edit can
50
+ silently fail validation and make the collection vanish from the UI. It writes
51
+ the canonical `data/skills/<slug>/schema.json` and mirrors it for you — same
52
+ destination as authoring, just validated. (Creating a *new* collection still
53
+ means writing `SKILL.md` + `schema.json` under `data/skills/<slug>/`, since
54
+ there's nothing to `getSchema` yet.)
55
+ - **Do NOT use the `mc-` prefix** for skills you create. `mc-*` is reserved for
56
+ the bundled presets (`mc-cooking-coach`, `mc-library`, `mc-wiki-*`,
57
+ `mc-manage-*`); the server overwrites those on every boot, so your edits would
58
+ be lost. (The billing collections — `clients`, `worklog`, `invoice`, `profile`
59
+ — are now recipe-authored under these plain slugs, NOT `mc-` presets; see the
60
+ worked reference below.)
61
+ - **`<slug>` rules**: lowercase letters, digits, hyphens; no leading / trailing
62
+ hyphen; 1–64 chars (e.g. `recipes`, `book-club`, `gym-log`). It doubles as the
63
+ URL (`/collections/<slug>`) and the directory name.
64
+ - The user opens the collection at **`/collections/<slug>`**. Link a specific
65
+ record with `?selected=<id>` (e.g. `/collections/recipes?selected=carbonara`).
66
+
67
+ ## SKILL.md
68
+
69
+ Standard skill front-matter plus prose teaching _future-you_ how to maintain the
70
+ records. Keep it short and operational:
71
+
72
+ ```markdown
73
+ ---
74
+ name: recipes
75
+ description: A personal recipe box. Use whenever the user asks to add, list,
76
+ edit, or remove a recipe. Records live at `data/recipes/items/<id>.json`
77
+ (one JSON per recipe); the user views them at `/collections/recipes`,
78
+ rendered from `schema.json` by the host. Record I/O via the
79
+ `manageCollection` tool (raw Read / Write / Edit on the JSON files is the
80
+ escape hatch); schema/structure edits via `manageCollection`
81
+ `schemaDocs` / `getSchema` / `putSchema`.
82
+ ---
83
+
84
+ # Recipes (schema-driven collection)
85
+
86
+ ## Record shape
87
+
88
+ - `id` — kebab-case slug, primary key (the filename, no extension)
89
+ - `title` — string, required
90
+ - ... (one bullet per field; note which are host-computed and must NOT be written)
91
+
92
+ ## What to do
93
+
94
+ **Add / Update** — `manageCollection` putItems: each row is validated against
95
+ the schema BEFORE the write; fix any `rejected` row from its `problem` text
96
+ and retry just those. Use `mode: "create"` when adding so an id collision is
97
+ rejected instead of silently overwritten, and `mode: "merge"` with a partial
98
+ row (`{ id, <changed fields> }`) when updating — the default upsert replaces
99
+ the WHOLE record and would erase every optional field the row omits.
100
+ **List / Read** — `manageCollection` getItems: the only way to see
101
+ host-computed `derived` / `toggle` / `embed` values (the stored JSON never
102
+ contains them); pass `ids` / `fields` on large collections.
103
+ **Delete** — remove the record file.
104
+ **Change the schema** (add / rename / remove a field, view, or action) —
105
+ `manageCollection` `schemaDocs` for the field DSL, `getSchema` to read the
106
+ current schema, then `putSchema` to validate-and-write it. Do NOT hand-edit
107
+ `schema.json` with Read / Write / Edit — `putSchema` validates the whole schema
108
+ first and tells you exactly what's wrong, where a raw edit can silently fail
109
+ discovery's validation and make the collection vanish.
110
+ Don't recite the whole table in chat. After adding or updating a record,
111
+ call `presentCollection` (with the collection slug and the record's id) to
112
+ show it inline; for a plain "show/list" request, call `presentCollection`
113
+ with just the slug.
114
+ ```
115
+
116
+ Write the `description` so it tells _you_ (in a future session) exactly when to
117
+ reach for this skill and where the records live — that text is what gets matched
118
+ when the user makes a request.
119
+
120
+ ## schema.json — the DSL
121
+
122
+ Top-level shape (validated on discovery; a malformed schema is logged and
123
+ skipped, never crashes the host):
124
+
125
+ | Key | Meaning |
126
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `title` | Human name shown in the sidebar / header. Required. |
128
+ | `icon` | A **Material Symbols** name (`receipt_long`, `people`, `schedule`, `menu_book`). Required. |
129
+ | `dataPath` | Workspace-relative records folder, e.g. `data/recipes/items`. Must stay under the workspace. Required. |
130
+ | `primaryKey` | The field name whose value is the filename. That field MUST set `primary: true`. The value must be a valid record id (see the **Records** section's id-charset rule). Required. |
131
+ | `singleton` | Optional. When set, at most one record exists, pinned to this exact id (e.g. `me`). Host pre-fills + locks the create form and hides Add once it exists. |
132
+ | `fields` | Ordered map of field-name → field spec. **Insertion order = column order** in the table. Required. |
133
+ | `actions` | Optional array of per-record buttons (see below). |
134
+ | `completionField` | Optional. Name of the field whose value marks an item as "done" — when set, item-create fires a bell notification that clears once the field reaches one of `completionDoneValues`. Must name a real field in `fields`. Paired with `completionDoneValues` (both set, or both omitted). |
135
+ | `completionDoneValues` | Optional. Non-empty array of values that count as "done" for `completionField` (e.g. `["Done"]`, `["paid", "void"]`). Compared as strings. |
136
+ | `notifyWhen` | Optional. A `when` predicate (`{ "field": "...", "in": [...] }`) that **gates** the completion bell: fire it only for records matching the predicate (e.g. `{ "field": "priority", "in": ["high", "urgent"] }`). Requires `completionField`; `field` must name a real field. Absent ⇒ notify for every open record. |
137
+ | `displayField` | Optional. Name of a field whose value is shown as the human-readable label in the completion notification's title (e.g. `Contacts: Jane Doe` instead of the opaque primaryKey). Must name a real field in `fields`. Falls back to the primaryKey value when unset or when the record's value is empty. |
138
+ | `triggerField` | Optional. Name of a `date` field that **delays** the completion bell until that date arrives (instead of firing on create). Requires `completionField` / `completionDoneValues` (the bell still clears via the done value). Must name a real `date` field. See "Time-gated bells" below. |
139
+ | `triggerLeadDays` | Optional. Non-negative integer: fire the bell this many days **before** `triggerField` (e.g. `10` = "remind me 10 days early"). Requires `triggerField`. Default `0` (fire on the trigger date). |
140
+ | `spawn` | Optional. Host-driven **recurrence**: when a record reaches a configured value (e.g. `status: paid`), the host auto-creates the next record with a forward-advanced `triggerField` date. Requires `triggerField`. See "Recurring obligations" below. |
141
+ | `calendarField` | Optional. Name of a `date` (or `datetime`) field that anchors the **calendar view** (a month grid; each record lands on its date cell). When unset, the table↔calendar toggle still appears if the schema has any `date`/`datetime` field — the first one is used, switchable in-view. Set this to pin a specific anchor. Must name a real `date`/`datetime` field. See "Calendar view" below. |
142
+ | `calendarEndField` | Optional. A second `date`/`datetime` field marking the END of a multi-day span on the calendar (the record renders across `calendarField` → this date). Requires `calendarField`. Must name a real `date`/`datetime` field. |
143
+ | `calendarTimeField` | Optional. Name of a string field holding a free-form time or time-range (`"14:00-17:00"`, `"17:00-"`, `"16:30"`) used to place records on the calendar's **day (time-allocation) view**. Consulted only when the date fields are date-only — a `datetime` anchor/end pair carries its own clock and takes precedence. Requires `calendarField`. See "Calendar view" below. |
144
+ | `kanbanField` | Optional. Name of an `enum` field that groups records into columns on the **Kanban board** (one column per declared value). When unset, the Kanban toggle still appears if the schema has any `enum` field — the first one is used, switchable in-view. Set this to pin a specific group field. Must name a real `enum` field. See "Kanban view" below. |
145
+ | `views` | Optional. Custom (LLM-authored) HTML views: `[{ id, label, icon?, file, capabilities? }]`. Each renders an HTML file under `views/*.html` in a sandboxed iframe over the records, for layouts the built-ins don't cover (year/quarter overview, Gantt, report). `capabilities` is `["read"]` (default) or `["read","write"]`. See "Custom views" below and **`config/helps/custom-view.md`** for the full authoring contract. |
146
+
147
+ ### Field types
148
+
149
+ `string` · `text` (multi-line) · `email` · `number` · `date` (`YYYY-MM-DD`) ·
150
+ `datetime` (`YYYY-MM-DDTHH:MM`) · `boolean` · `markdown` · `money` · `enum` ·
151
+ `ref` · `embed` · `table` · `derived` · `image` · `file` · `toggle`
152
+
153
+ Every field spec needs a `type` and a `label`. Extra keys by type:
154
+
155
+ - **`datetime`** — no extra keys. Stored as a `YYYY-MM-DDTHH:MM` string and
156
+ edited with a native date+time picker. Use it (as `calendarField` /
157
+ `calendarEndField`) when an event has a real start/end clock — the calendar's
158
+ day view then draws each record as a proportional time block. For the common
159
+ "date column + separate time column" shape, keep `date` and point
160
+ `calendarTimeField` at the time string instead.
161
+ - **`enum`** — `values: ["draft", "sent", "paid"]` (non-empty strings). Renders
162
+ a `<select>`; stored as a plain string.
163
+ - **`money`** — `currency: "USD"` (ISO 4217, defaults to USD). Stored as a plain
164
+ decimal; currency is display-only.
165
+ - **`ref`** — `to: "<target-slug>"`. Stores the target record's primary-key
166
+ slug; host renders a clickable link + a dropdown picker populated from the
167
+ target collection. Example: `{ "type": "ref", "to": "clients", "label": "Client" }`.
168
+ A `derived` field on the same record can also **dereference** a `ref` to read
169
+ a numeric column off the record it points at — see the cross-collection
170
+ formula syntax below.
171
+ - **`embed`** — `to: "<target-slug>"`, `id: "<record-id>"`. Pulls a _fixed_
172
+ record from another collection into the read-only detail view (display-only,
173
+ **nothing is stored** on this record). Example: an invoice embedding the
174
+ user's own profile: `{ "type": "embed", "to": "profile", "id": "me" }`.
175
+ - **`table`** — `of: { <col>: <sub-field-spec>, ... }`. An array of rows. Each
176
+ sub-field is a flat spec; sub-fields **cannot** be `table` or `derived`
177
+ (no nested tables, no computed columns).
178
+ - **`derived`** — `formula: "<expr>"`, optional `display` (`number` default, or
179
+ `money` / `string` / `date`) and `currency`. **Read-only, host-computed** —
180
+ you NEVER write derived values into the JSON; the host recomputes them on
181
+ every render and the form refuses to persist them.
182
+ - **`image`** — stores a **workspace-relative image path** as a plain string
183
+ (e.g. `data/attachments/2026/05/<id>.jpg` — the exact path from an
184
+ `[Attached file: ...]` marker when the user attaches a photo). The host
185
+ renders it as an `<img>` in the **detail view** (it is intentionally not a
186
+ list-table column — a per-row image fetch is too expensive for a large
187
+ collection). No extra keys. Great for photos like a business card: read the
188
+ details off the attached image and write its path into the image field.
189
+ Write the bare workspace-relative path — never an `/api/files/raw?...` URL.
190
+ - **`file`** — stores a **workspace-relative file path** as a plain string (e.g.
191
+ `artifacts/html/the-solar-system-1777158558023.html`). Rendered as a
192
+ **clickable link** in both the list table and the detail view (unlike `image`,
193
+ which is detail-only — a link is cheap per-row). Clicking an HTML or SVG
194
+ artifact opens its **rendered** form in a new browser tab (the live app /
195
+ drawing); any other path opens in the **File Explorer**. No extra keys. Ideal
196
+ for a "my apps" collection where each record points at a generated HTML app —
197
+ the user launches it straight from the row. Write the bare workspace-relative
198
+ path — never an `/artifacts/...` or `/api/files/raw?...` URL.
199
+ - **`toggle`** — `field: "<enum-field>"`, `onValue`, `offValue`. A checkbox that
200
+ is a pure **projection** of an `enum` field — it **stores nothing** of its own
201
+ (like `derived`/`embed`). Checked when the projected enum equals `onValue`;
202
+ toggling writes `onValue` / `offValue` back to that enum. `onValue`/`offValue`
203
+ must be members of the enum's `values`. Use it to front a kanban `status` with
204
+ a "done" checkbox while keeping the enum as the single source of truth — e.g.
205
+ `{ "type": "toggle", "label": "Done", "field": "status", "onValue": "Done", "offValue": "Todo" }`.
206
+ Renders an interactive checkbox in the list table and on the kanban card (when
207
+ it projects the board's group field); read-only in the detail view.
208
+ **A todo / task list should almost always include one** — it's the row/card
209
+ "done" checkbox. Without it, `status` only shows as a dropdown and a kanban
210
+ column, with no checkbox to tick. `offValue` is the status to return to on
211
+ uncheck (the default open column, e.g. `"Todo"`).
212
+
213
+ ### Conditional field visibility (`when`)
214
+
215
+ Any field may carry an optional `when: { field, in: [...] }` predicate to hide
216
+ itself until another field on the same record matches — the same shape used to
217
+ gate `actions`. The field shows only when `String(record[when.field])` is one of
218
+ `in`; absent ⇒ always shown. `when.field` MUST name another top-level field
219
+ (validated on discovery).
220
+
221
+ ```json
222
+ "visited": { "type": "boolean", "label": "Visited" },
223
+ "rating": { "type": "number", "label": "Rating", "when": { "field": "visited", "in": ["true"] } }
224
+ ```
225
+
226
+ Here `rating` stays hidden until `visited` is checked (booleans stringify, so
227
+ match `"true"` / `"false"`). The gate applies everywhere the field renders: the
228
+ list cell goes **blank**, the edit-form input hides/shows **live** as the gating
229
+ field changes, and the detail view omits it. It is **purely presentational** —
230
+ a hidden field's stored value is never cleared, so re-matching the gate restores
231
+ it. Use it for fields that only make sense in a given state (a rating before
232
+ you've visited, a shipped-date before an order ships). Only honoured on
233
+ top-level fields, not inside a `table`'s `of`.
234
+
235
+ ### Derived-formula syntax
236
+
237
+ A tiny expression evaluated against the record (pure evaluator, no `eval`;
238
+ returns `null` on any failure). Supported:
239
+
240
+ - arithmetic `+ - * /` and parentheses
241
+ - identifier references to **top-level** fields (`subtotal * taxRate`)
242
+ - `sum(tableField[].col)` — sum a column across table rows
243
+ - `sum(tableField[].col * tableField[].col)` — sum a per-row product
244
+ - `<refField>.<col>` — **dereference a `ref` field** and read a numeric column
245
+ off the record it points at (a live cross-collection lookup)
246
+
247
+ Example: `subtotal` = `sum(lineItems[].quantity * lineItems[].rate)`,
248
+ `tax` = `subtotal * taxRate`, `total` = `subtotal + tax`.
249
+
250
+ #### Cross-collection lookups (`<refField>.<col>`)
251
+
252
+ When a field is a `ref` to another collection, a `derived` formula can reach
253
+ into the referenced record and pull a numeric column out of it. This lets one
254
+ collection compute against data **owned by another** without copying that data.
255
+
256
+ Canonical use — a portfolio that values holdings against a separate price list:
257
+
258
+ ```jsonc
259
+ // my-portfolio/schema.json (one record per holding)
260
+ "fields": {
261
+ "ticker": { "type": "ref", "to": "stock-quotes", "label": "Stock" }, // stores e.g. "AAPL"
262
+ "shares": { "type": "number", "label": "Shares" },
263
+ "value": { "type": "derived", "formula": "shares * ticker.price", // ← reads price from the quotes row
264
+ "display": "money", "currency": "USD", "label": "Value" }
265
+ }
266
+ ```
267
+
268
+ Here `ticker.price` resolves the `ticker` ref to its `stock-quotes` record and
269
+ reads that record's `price`. `price` lives **only** in `stock-quotes`; the
270
+ portfolio never stores a copy, so a quote refresh in `stock-quotes` is the
271
+ single source of truth for every holding's value.
272
+
273
+ Rules and limits:
274
+
275
+ - The left side must be a `ref` field **on this same record**; the right side is
276
+ a single column name. Only the `<field>.<col>` shape — no `a.b.c` chains, no
277
+ dereferencing inside `sum(...)`.
278
+ - The referenced column must hold a number (or a numeric string). A missing
279
+ column, a non-numeric value, or a **dangling ref** (the slug points at a row
280
+ that doesn't exist) makes the whole formula fail soft to an em-dash (`—`),
281
+ same as any other formula error.
282
+ - The referenced column may itself be a **`derived`** field in the target
283
+ collection (the host computes the target's own derived fields before the
284
+ lookup) — _as long as_ that target formula is target-local. A target derived
285
+ field that in turn derefs a **third** collection won't resolve (only one hop
286
+ is loaded) and reads as `—`.
287
+ - The target collection is loaded **when the page opens**. If a value changes in
288
+ the target while the viewing collection is already open (e.g. you refresh a
289
+ price in `stock-quotes` in another tab), the derived value updates on the next
290
+ reload — not instantly across tabs.
291
+ - It's still per-record: each holding computes `shares * ticker.price` from its
292
+ own `ticker`/`shares`. To total the portfolio, add a one-record summary
293
+ collection or read the values off the list view — there is no cross-row sum
294
+ over a joined column.
295
+
296
+ ### Actions (per-record buttons)
297
+
298
+ Each entry in `actions` renders a button in the read-only detail view. The only
299
+ `kind` today is `"chat"`: clicking it starts a **new chat in a role**, seeded
300
+ with a template + the record data — the role then does the work with its tools.
301
+ This is how hard logic the schema can't express (PDF generation, bookkeeping
302
+ journals, drafting an email) gets delegated to natural language.
303
+
304
+ ```json
305
+ {
306
+ "id": "pdf", // unique within the schema
307
+ "label": "Generate PDF", // button text (English)
308
+ "icon": "picture_as_pdf", // Material Symbols name
309
+ "kind": "chat",
310
+ "role": "accounting", // which role the new chat runs in
311
+ "template": "templates/invoice.md", // skill-relative; no `..`, no leading `/`
312
+ "when": { "field": "status", "in": ["paid"] } // optional: show only when record.status ∈ {paid}
313
+ }
314
+ ```
315
+
316
+ - `template` is a path **inside the skill dir** (host reads it path-safely).
317
+ Write the action's instructions there in plain English; the host prepends the
318
+ record JSON as sanitized, passive data and hands the whole thing to the role.
319
+ - `when` is both the visibility rule **and** the authorization rule — the host
320
+ re-checks it server-side, so a button gated on `status: paid` can't be invoked
321
+ for a draft. Omit `when` ⇒ always shown.
322
+ - You do **not** trigger actions yourself; point the user at the button.
323
+
324
+ ### Collection-level actions (header buttons)
325
+
326
+ `collectionActions` is a second array, same entry shape as `actions`, but the
327
+ buttons render in the **collection header** instead of a record's detail view.
328
+ Use one when the work spans the whole collection rather than a single record —
329
+ e.g. a course-level "Learn / continue" button on a lessons collection that picks
330
+ the next lesson, or "Monthly close" on an invoice ledger.
331
+
332
+ The difference is what the seed prompt carries: a per-record action injects that
333
+ one record's JSON; a collection-level action injects a **compact progress
334
+ summary of every record** — each record projected down to the schema's
335
+ `primaryKey`, `displayField`, `completionField`, and `kanbanField` values (long
336
+ text / markdown / html / file fields are left out, so the prompt stays small).
337
+
338
+ ```json
339
+ "collectionActions": [
340
+ { "id": "continue", "label": "Continue", "icon": "play_arrow",
341
+ "kind": "chat", "role": "tutor", "template": "templates/continue.md" }
342
+ ]
343
+ ```
344
+
345
+ - Same `id` uniqueness rule (within `collectionActions`); same path-safe
346
+ `template`; same `role`-seeds-a-new-chat behavior.
347
+ - `when` is **ignored** here — there is no record to gate on. Always shown.
348
+
349
+ ### Completion tracking (bell notifications)
350
+
351
+ Declare `completionField` + `completionDoneValues` at the top level of the
352
+ schema to wire a record's lifecycle into the bell:
353
+
354
+ ```json
355
+ {
356
+ "title": "Todos",
357
+ "icon": "check_circle",
358
+ "dataPath": "data/todos/items",
359
+ "primaryKey": "id",
360
+ "fields": {
361
+ "id": { "type": "string", "label": "ID", "primary": true, "required": true },
362
+ "title": { "type": "string", "label": "Title", "required": true },
363
+ "status": { "type": "enum", "values": ["Todo", "Doing", "Done"], "label": "Status", "required": true }
364
+ },
365
+ "completionField": "status",
366
+ "completionDoneValues": ["Done"],
367
+ "displayField": "title"
368
+ }
369
+ ```
370
+
371
+ Behaviour:
372
+
373
+ - **On create** the host fires a bell notification (titled
374
+ `<schema.title>: <label>`, where `<label>` is the record's `displayField`
375
+ value when declared — falling back to the primaryKey `<id>` otherwise;
376
+ click-navigates to `/collections/<slug>?selected=<id>` so the item's detail
377
+ opens) — unless the new record is **born done** (its `completionField` value
378
+ is already in `completionDoneValues`), in which case nothing fires. The entry
379
+ is published with `lifecycle: "action"` so it persists prominently in the
380
+ bell until the obligation resolves.
381
+ - **On update** the host clears the notification when `completionField`
382
+ transitions **into** a done value. Un-completing (Done → Todo) does NOT
383
+ re-fire; firing is bound to create, by design.
384
+ - **On delete** the host clears any matching notification so a removed record
385
+ can't leak a stale entry.
386
+
387
+ The pair is bundled — declaring one without the other fails schema validation.
388
+ `completionField` must name a real field; a typo is rejected at load. Works
389
+ with any field type whose stringified value is comparable (`enum`, `string`,
390
+ `boolean`, …) — e.g. `completionField: "status"` + `completionDoneValues:
391
+ ["paid", "void"]` on an invoice, or `completionField: "shipped"` +
392
+ `completionDoneValues: ["true"]` on an order.
393
+
394
+ Set `displayField` to make the bell title readable: with `displayField:
395
+ "title"` the notification reads `Todos: Buy milk` instead of `Todos: t-0042`.
396
+ It must name a real field; an empty value on a given record falls back to the
397
+ primaryKey for that record.
398
+
399
+ > **Building a todo / task list?** When your `completionField` is a status enum,
400
+ > also add a `toggle` field (the row/card "done" checkbox) and `notifyWhen`
401
+ > (fire the bell only for high-priority items, not every record). See the full
402
+ > recipe in **"Worked example: a Todo list"** below — that's the canonical
403
+ > template; don't reinvent it from these fragments.
404
+
405
+ ### Time-gated bells (`triggerField`)
406
+
407
+ By default the completion bell fires **on create**. Add `triggerField` — the
408
+ name of a `date` field — to instead **hold the bell until that date arrives**.
409
+ The item is still tracked, but its bell stays silent while the trigger date is
410
+ in the future and appears once the clock reaches it (compared at day
411
+ granularity in the server's local timezone). It clears the same way as any
412
+ completion bell — when `completionField` reaches a `completionDoneValues` value.
413
+
414
+ ```json
415
+ {
416
+ "title": "Reminders",
417
+ "icon": "event",
418
+ "dataPath": "data/reminders/items",
419
+ "primaryKey": "id",
420
+ "fields": {
421
+ "id": { "type": "string", "label": "ID", "primary": true, "required": true },
422
+ "what": { "type": "string", "label": "What", "required": true },
423
+ "dueOn": { "type": "date", "label": "Remind on", "required": true },
424
+ "status": { "type": "enum", "values": ["pending", "done"], "label": "Status", "required": true }
425
+ },
426
+ "completionField": "status",
427
+ "completionDoneValues": ["done"],
428
+ "displayField": "what",
429
+ "triggerField": "dueOn"
430
+ }
431
+ ```
432
+
433
+ This is the "nudge me about this on date X, until I mark it done" pattern. Notes:
434
+
435
+ - `triggerField` **requires** the completion pair (validation rejects it
436
+ otherwise — there'd be no bell to gate or clear).
437
+ - The named field must be type `date`; its value is parsed as `YYYY-MM-DD`.
438
+ - Firing is **derived from the clock, not stored** — so if the server was down
439
+ when the date passed, the bell simply appears at the next boot/check. Pushing
440
+ the date back into the future retracts a bell that already fired.
441
+ - Granularity is whole days (no time-of-day).
442
+
443
+ #### Lead time — fire it early (`triggerLeadDays`)
444
+
445
+ Keep `triggerField` as the **real** due date and add `triggerLeadDays` to fire
446
+ the bell some days ahead of it. "Remind me 10 days before rent is due":
447
+
448
+ ```json
449
+ "triggerField": "dueOn",
450
+ "triggerLeadDays": 10
451
+ ```
452
+
453
+ The bell now appears once the clock reaches `dueOn − 10 days`, and still clears
454
+ when the item is marked done. The lead is applied at fire time (not stored), so
455
+ it **composes with `spawn`**: every recurred month fires 10 days before its own
456
+ `dueOn`, with no extra bookkeeping. It's a non-negative whole number of days and
457
+ requires `triggerField`. This is a single earlier bell — not an escalating
458
+ multi-stage reminder (info → warning → urgent), which is intentionally out of
459
+ scope for collections.
460
+
461
+ ### Recurring obligations (`spawn`)
462
+
463
+ Add a `spawn` block to make a collection **recur**: when a record satisfies a
464
+ predicate (by default, when it becomes "done"), the host automatically creates
465
+ the **next** record with its `triggerField` advanced. Combined with
466
+ `triggerField`, this expresses periodic obligations — rent, subscriptions,
467
+ renewals, recurring payments — with no work from you per cycle: mark this
468
+ month's rent `paid`, and next month's pending record appears on its own.
469
+
470
+ ```json
471
+ "triggerField": "dueOn",
472
+ "spawn": {
473
+ "when": { "field": "status", "in": ["paid"] },
474
+ "every": { "unit": "month", "interval": 1, "dayOfMonth": 10 },
475
+ "carry": ["amount", "payee"],
476
+ "set": { "status": "pending" }
477
+ }
478
+ ```
479
+
480
+ - **`when`** — a `{ field, in: [...] }` predicate (same shape as field/action
481
+ `when`) that fires the spawn. Omit it to default to "the completion-done
482
+ condition" (i.e. spawn when this record is done).
483
+ - **`every`** — how to advance `triggerField` from this record to the next:
484
+ - `unit`: `day` · `week` · `month` · `year`; `interval`: a positive integer
485
+ (so `unit: "month", interval: 3` = quarterly, `unit: "year", interval: 1`
486
+ = annual).
487
+ - `dayOfMonth` (month/year only): the **canonical** day-of-month anchor
488
+ (1–31, or `"last"` for the month's last day). Use it for day ≥ 29 so
489
+ short months don't cause drift — "31st of every month" yields
490
+ 31 → 28/29 → 31 → 30 … correctly. Omit it for days ≤ 28 and the source
491
+ date's day is preserved.
492
+ - **Field-driven interval** (one list, mixed cadences): instead of a single
493
+ `{ unit, interval }`, `every` may select the interval **per record** from
494
+ an `enum` field. Use `{ "fromField": "<enum field>", "map": { <value>:
495
+ { unit, interval, … } } }` — the host reads the record's value and
496
+ advances by the matching entry. This lets one collection carry daily,
497
+ weekly, and monthly obligations together:
498
+
499
+ ```json
500
+ "every": {
501
+ "fromField": "frequency",
502
+ "map": {
503
+ "daily": { "unit": "day", "interval": 1 },
504
+ "weekly": { "unit": "week", "interval": 1 },
505
+ "monthly": { "unit": "month", "interval": 1, "dayOfMonth": 1 }
506
+ }
507
+ }
508
+ ```
509
+
510
+ Rules (all enforced at write time): `fromField` must name a top-level
511
+ `enum` field; the `map` keys must **exactly cover** that enum's `values`
512
+ (no missing or extra keys); and `fromField` must be in `carry` (or written
513
+ by `set`) so the successor keeps its frequency and the chain keeps
514
+ recurring. Each `map` value is a literal `every` (same `unit` / `interval`
515
+ / `dayOfMonth` rules as above).
516
+ - **`carry`** — record fields copied verbatim onto the successor (must name
517
+ real fields). Fields not in `carry` / `set` / the trigger+primary keys start
518
+ blank.
519
+ - **`set`** — fields forced to fixed values on the successor (typically
520
+ resetting the status to its pending value).
521
+
522
+ How it behaves (worth understanding so it doesn't surprise you):
523
+
524
+ - The successor's id is **deterministic**: `<stem>-<YYYYMMDD>` (the source id
525
+ with any trailing `-YYYYMMDD` replaced). So `rent` → `rent-20260610` →
526
+ `rent-20260710`. Creation is **create-if-absent** — it never overwrites, so
527
+ re-running is harmless and any edits you make to a successor are preserved.
528
+ - **Forward-only**: un-doing the source (e.g. `paid` → `pending`) does NOT
529
+ delete an already-created successor. And because spawning is convergent,
530
+ deleting the successor while the source still matches `when` will **re-create
531
+ it**. To genuinely **stop a recurrence**, move the source to a status that is
532
+ _not_ in `spawn.when` (e.g. an `archived` value) — that's the supported "end
533
+ it" gesture.
534
+ - `spawn` **requires** `triggerField` (the successor's date is `triggerField`
535
+ advanced by `every`).
536
+
537
+ This covers _periodic_ obligations. It does **not** do escalating, multi-stage
538
+ reminders over a long prep window (info → warning → urgent) — that is
539
+ intentionally out of scope for collections.
540
+
541
+ ### Calendar view
542
+
543
+ Any collection that has at least one `date` (or `datetime`) field gains a
544
+ **table ↔ calendar** toggle in its header — **zero config**. The calendar is a
545
+ month grid where each record lands on the day cell matching its date. Clicking a
546
+ record **chip** opens the same detail/edit panel the table uses; clicking
547
+ anywhere else in a day cell opens the **day (time-allocation) view** — a popup
548
+ vertical timeline of that day (see below), whose **+** button starts a new record
549
+ prefilled to that day.
550
+
551
+ ```json
552
+ {
553
+ "title": "Events",
554
+ "icon": "event",
555
+ "dataPath": "data/events/items",
556
+ "primaryKey": "id",
557
+ "fields": {
558
+ "id": { "type": "string", "label": "ID", "primary": true, "required": true },
559
+ "name": { "type": "string", "label": "Name", "required": true },
560
+ "on": { "type": "date", "label": "Date", "required": true },
561
+ "until": { "type": "date", "label": "End" }
562
+ },
563
+ "displayField": "name",
564
+ "calendarField": "on",
565
+ "calendarEndField": "until"
566
+ }
567
+ ```
568
+
569
+ Notes:
570
+
571
+ - **No schema change is needed to get the toggle** — it appears whenever a `date`
572
+ field exists. The two keys only _tune_ it: `calendarField` pins which date
573
+ anchors the grid (otherwise the first `date` field is used, and the user can
574
+ switch in-view when there are several); `calendarEndField` makes a record span
575
+ multiple days (`calendarField` → `calendarEndField`, inclusive).
576
+ - `displayField` sets the chip label (falls back to the primary key).
577
+ - Records whose anchor date is missing or unparseable are listed in a small
578
+ "No date" tray under the grid — never silently dropped.
579
+ - The calendar is purely a **rendering** of the records: it adds no storage and
580
+ fires nothing. It composes with `triggerField` / `spawn` (which drive bells and
581
+ recurrence) but is independent of them.
582
+ - This is the collection-native calendar — the way to give the user a
583
+ calendar of dated records. (The old standalone Calendar view +
584
+ `manageCalendar` tool were removed; `calendarField` is its replacement.)
585
+
586
+ #### Day view (time allocation)
587
+
588
+ Clicking a day's number badge opens a popup vertical timeline (a 24-hour grid)
589
+ showing how that day's records are allocated across the clock. Records need a
590
+ **time of day** to draw as time blocks; supply it one of two ways:
591
+
592
+ - A `datetime` `calendarField` (and optionally `calendarEndField`) — the clock
593
+ comes from the field value itself (`2026-06-11T14:00`).
594
+ - A `date` `calendarField` **plus** `calendarTimeField` naming a string field
595
+ with a free-form time or range. Recognised shapes:
596
+
597
+ | Time value | Day-view rendering |
598
+ | ------------------------------------ | ------------------------------------------------- |
599
+ | `"14:00-17:00"` (a range) | a proportional **time block** from 14:00 to 17:00 |
600
+ | `"17:00-"` or `"16:30"` (start only) | a **single line** at that time (no known end) |
601
+ | `"終日"`, blank, or unparseable | a chip in the **all-day strip** at the bottom |
602
+
603
+ Separators `-`, `–`, `—`, `~`, `〜`, `~` are all accepted. Overlapping blocks
604
+ split into side-by-side lanes; a multi-day `datetime` span is clamped to each
605
+ day with ▲/▼ arrows marking where it continues. Example (`date` + `time`
606
+ column):
607
+
608
+ ```json
609
+ {
610
+ "title": "Engagements",
611
+ "icon": "event",
612
+ "dataPath": "data/engagements/items",
613
+ "primaryKey": "id",
614
+ "fields": {
615
+ "id": { "type": "string", "label": "ID", "primary": true, "required": true },
616
+ "title": { "type": "string", "label": "Title", "required": true },
617
+ "date": { "type": "date", "label": "Date", "required": true },
618
+ "time": { "type": "string", "label": "Time" }
619
+ },
620
+ "displayField": "title",
621
+ "calendarField": "date",
622
+ "calendarTimeField": "time"
623
+ }
624
+ ```
625
+
626
+ ### Kanban view
627
+
628
+ Any collection that has at least one `enum` field gains a **Kanban board** toggle
629
+ in its header — **zero config**. The board renders one column per declared enum
630
+ value (in `values` order), plus a trailing **Uncategorized** column for
631
+ empty/unknown values (omitted when the group enum is `required`). Dragging a card
632
+ between columns writes that enum field; clicking a card opens the same
633
+ detail/edit panel.
634
+
635
+ ```json
636
+ {
637
+ "title": "Tasks",
638
+ "icon": "checklist",
639
+ "dataPath": "data/tasks/items",
640
+ "primaryKey": "id",
641
+ "fields": {
642
+ "id": { "type": "string", "label": "ID", "primary": true, "required": true },
643
+ "title": { "type": "string", "label": "Title", "required": true },
644
+ "status": { "type": "enum", "label": "Status", "values": ["Backlog", "Todo", "In Progress", "Done"] },
645
+ "done": { "type": "toggle", "label": "Done", "field": "status", "onValue": "Done", "offValue": "Todo" }
646
+ },
647
+ "displayField": "title",
648
+ "kanbanField": "status"
649
+ }
650
+ ```
651
+
652
+ Notes:
653
+
654
+ - **No schema change is needed to get the toggle** — it appears whenever an
655
+ `enum` field exists. `kanbanField` only _tunes_ which enum groups the board
656
+ (otherwise the first `enum` field is used, switchable in-view).
657
+ - **The enum is the single source of truth.** For a todo-style "done" checkbox,
658
+ use a `toggle` field projecting the status enum (above) — do NOT add a separate
659
+ stored boolean. Checking the box sets `status` to the done value (and moves the
660
+ card to that column); dragging the card to the Done column checks the box. They
661
+ are the same write.
662
+ - Columns are not draggable (order comes from the enum's `values`) and there is
663
+ no manual ordering within a column — a drop only changes the enum value.
664
+ - Like the calendar, the board is purely a **rendering** of the records: it adds
665
+ no storage. `completionField` / `completionDoneValues` (bells) are independent
666
+ but pair naturally with the Done column.
667
+ - **Building a todo / task list?** Read `config/helps/todo-collection.md` — the
668
+ complete, copy-pasteable recipe (status enum + `done` toggle + priority bells +
669
+ calendar) plus the legacy-`todo-plugin` migration steps.
670
+
671
+ ### Custom views
672
+
673
+ When the built-in views (table / calendar / kanban / dashboard) don't fit what
674
+ the user wants to _see_ — a year/quarter overview, a Gantt bar, a printable
675
+ report — author a **custom view**: an HTML file the host renders in a sandboxed
676
+ iframe over the records. Register it in `views[]` (above); it becomes a button
677
+ in the collection's view-mode selector.
678
+
679
+ - The view reads (and, with `["read","write"]`, writes) records through a
680
+ scoped token injected as `window.__MC_VIEW` — it never touches files directly.
681
+ - It is sandboxed: inline scripts + a CDN allowlist only, and `fetch` is
682
+ limited to the collection's own data endpoint (no third-party calls).
683
+ - Least privilege: declare `["read"]` unless the view edits records.
684
+ - It can stay **live**: call `window.__MC_VIEW.onChange(reload)` and the view
685
+ re-fetches whenever the records change — the assistant editing them in chat,
686
+ another tab, a feed refresh, or an auto-spawned record (like the built-in
687
+ views). One line; no polling.
688
+
689
+ The host holds **zero** view-specific code — the view is data, like the rest of
690
+ the collection. Full authoring contract (the `window.__MC_VIEW` shape, the
691
+ read/write API, the sandbox rules, and two complete sample views):
692
+ **`config/helps/custom-view.md`**. Read it before authoring a view.
693
+
694
+ ### Worked example: a Todo list
695
+
696
+ The full todo recipe — complete `schema.json`, `SKILL.md`, a sample record, and
697
+ the legacy-`todo-plugin` migration steps — has its own file:
698
+ **`config/helps/todo-collection.md`**. Read it whenever you create or migrate a
699
+ todo / task list; it's the canonical template, so copy it rather than assembling
700
+ one from the fragments above. The one rule to remember: the `status` enum is the
701
+ single source of truth and the "done" checkbox is a `toggle` field projecting it
702
+ — **omit the toggle and the list has no checkbox.**
703
+
704
+ ## Records — one JSON object per file
705
+
706
+ - Write each record to `<dataPath>/<id>.json` via the **Write** tool; the `id`
707
+ field's value is the filename (no extension).
708
+ - **Id charset** (enforced by `safeRecordId` in
709
+ `packages/plugins/collection-plugin/src/server/paths.ts` — the single source of
710
+ truth; `manageCollection` rejects ids that fail it): start and end with a
711
+ letter or digit; inside, also `-`, `_`, and `.` are allowed (so natural keys
712
+ like a Slack ts `1718900000.123456` or a SemVer `1.2.3` work). **No** path
713
+ separators, **no** leading/trailing dot, and **no** `..` substring. If your
714
+ natural key contains anything else (a space, `/`, `:`, a leading dot), sanitise
715
+ it first — e.g. replace each illegal run with `_`. Note `manageCollection`
716
+ enforces this on every targeted read/write, so an id that only *looks* fine in
717
+ a full `getItems` listing but violates the rule can't be updated or deleted by
718
+ id — fix the id, don't work around it with raw file I/O.
719
+ - **The file MUST be valid JSON.** A malformed record is **silently skipped** at
720
+ read time (logged server-side, but invisible in the UI) — so one bad file out
721
+ of fifteen looks like "fourteen records vanished." The #1 cause is an
722
+ **unescaped double-quote inside a string value**: writing `"title": "がんは"細胞のバグ""`
723
+ closes the string early and corrupts the file. In free-text / prose fields
724
+ (`text`, `markdown`, a long `objective`), either escape every inner ASCII quote
725
+ as `\"`, or — better — use the language's own quotation marks (`「」`/`『』` for
726
+ Japanese, `‘ ’`/`“ ”` or `'…'` for English) so no escaping is needed.
727
+ `presentCollection` re-validates the records and reports any unreadable /
728
+ malformed / schema-violating files back to you (a `⚠️` in its result) — so
729
+ always follow a batch of writes with a `presentCollection` call and **act on
730
+ any ⚠️ it returns** (Read → fix → Write), rather than assuming every record
731
+ landed.
732
+ - **List the directory first** and pick a fresh id rather than silently
733
+ overwriting. Update = Read, merge, Write back (preserve fields you weren't
734
+ asked to change). Delete = remove the file.
735
+ - **Never write `derived` fields**, and never write an `embed` field — both are
736
+ display-only / host-computed.
737
+ - Leave optional fields out of the JSON entirely rather than writing empty
738
+ strings.
739
+ - For a `ref` field, write the raw target slug, and make sure that record
740
+ actually exists in the target collection — an invalid slug renders as a broken
741
+ link. The host enforces structure and safety; **you own semantic correctness**
742
+ (valid refs, sane values).
743
+
744
+ ## End-to-end: creating a new collection skill
745
+
746
+ 1. Pick a `<slug>` (lowercase-hyphen, no `mc-` prefix) and a `dataPath`
747
+ (`data/<name>/items`).
748
+ 2. Write `data/skills/<slug>/schema.json` — `title`, `icon`, `dataPath`,
749
+ `primaryKey` (with the matching field flagged `primary: true`), and the
750
+ `fields` map in the order you want columns. Add `actions` +
751
+ `data/skills/<slug>/templates/*.md` only if the collection needs delegated
752
+ behaviour. (The bridge mirrors these into `.claude/skills/<slug>/`.)
753
+ 3. Write `data/skills/<slug>/SKILL.md` — front-matter `name` + `description`,
754
+ then the record-shape bullets and CRUD conventions.
755
+ 4. Tell the user it's ready at `/collections/<slug>`. The bridge mirrors the
756
+ files and triggers a re-scan, so the host discovers it without a restart and
757
+ with no host code. If it doesn't appear: first confirm you wrote under
758
+ `data/skills/<slug>/` (NOT `.claude/skills/…`, which is gated and won't
759
+ mirror); then check your `schema.json` passed validation — primary key
760
+ flagged `primary: true`, `ref`/`embed` have a valid `to`, `enum` has
761
+ `values`, `table` has `of`, `derived` has `formula`, action ids unique,
762
+ `dataPath` under the workspace, `triggerField` names a real `date` field and
763
+ has the completion pair, `spawn` has `triggerField` and a valid `every`,
764
+ `calendarField` / `calendarEndField` name real `date`/`datetime` fields (and
765
+ `calendarEndField` requires `calendarField`), `calendarTimeField` names a real
766
+ field and requires `calendarField`, `kanbanField` names a real
767
+ `enum` field, any `toggle` field names a real `enum` `field` with its
768
+ `onValue` / `offValue` among that enum's `values`, and `notifyWhen` (if set)
769
+ requires `completionField` and names a real field.
770
+ (A schema that fails validation is logged server-side and silently skipped
771
+ at discovery.)
772
+
773
+ ## Editing an existing collection's schema
774
+
775
+ To change the structure of a collection that already exists (add a field,
776
+ rename a label, add a view or action), go through `manageCollection` rather than
777
+ hand-editing the file:
778
+
779
+ 1. `manageCollection` `schemaDocs` — reload this reference for the field DSL.
780
+ 2. `manageCollection` `getSchema` (slug) — read the current `schema.json`
781
+ verbatim. You don't need to know where the file lives.
782
+ 3. Apply your change to that object, then `manageCollection` `putSchema`
783
+ (slug, schema) — it validates the whole schema against the same rules
784
+ discovery enforces and either writes it (canonical `data/skills/<slug>/`,
785
+ mirrored for you) or returns the exact field + problem to fix and retry.
786
+ 4. Call `presentCollection` to show the updated collection.
787
+
788
+ Why not raw Read / Write / Edit on `schema.json`? A hand-edit that fails
789
+ validation is **silently skipped** at discovery — the collection disappears from
790
+ the UI with no error. `putSchema` catches the mistake before the write and hands
791
+ you an actionable message. (`putSchema` is edit-only and refuses user-scope and
792
+ `mc-*` preset collections; create a new collection with the Write flow above.)
793
+
794
+ ## Worked reference: the billing suite
795
+
796
+ The billing collections are the canonical examples. They ship as **recipes**
797
+ (copy-paste schemas + SKILL bodies), not as boot-overwritten presets — read the
798
+ recipe when the user wants any of them, and copy the schema verbatim:
799
+
800
+ - **`config/helps/billing-clients-worklog.md`** (Bundle A):
801
+ - **`clients`** — flat table (`string` / `email` / `text` / `markdown`). The
802
+ simplest possible collection; everything else `ref`s into it.
803
+ - **`worklog`** — adds a `ref` (`clientId → clients`), a `date`, a `number`, a
804
+ `boolean`. A companion data source.
805
+ - **`config/helps/billing-invoice.md`** (Bundle B):
806
+ - **`profile`** — a `singleton` (one record, id `me`): the issuer identity.
807
+ - **`invoice`** — the full toolkit in one schema: an `embed` issuer
808
+ (`profile/me`), a `ref` client (`clients`), a `table` of line items, three
809
+ `derived` money fields, an `enum` status, and four `actions` (PDF always-on;
810
+ sale / payment / void gated by `status` via `when`).