@medicine-wheel/app 0.19.0 → 0.20.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.
package/README.md CHANGED
@@ -391,20 +391,23 @@ Two chronicle episodes related through the Miadi lineage door (`POST /api/edges`
391
391
  between two `chronicle_episode` nodes) produce `chronicle.lineage.made`, which
392
392
  carries the manifest's sentence that says why.
393
393
 
394
- **Publishing.** Nothing leaves the machine until `MW_EVENTS_PUBLISH_URL` is set.
395
- Once it is, the wheel POSTs each event, in log order, after every write and
396
- every minute (`MW_EVENTS_PUBLISH_INTERVAL_MS`).
397
-
398
- - **Headers:** `Idempotency-Key`, `X-Event-Type`, and `Authorization: Bearer
399
- $MW_EVENTS_PUBLISH_TOKEN` when a token is set.
400
- - **Delivery records:** a cursor and receipts beside the log record
401
- `arrived`, `failed` (retried) or `refused` (passed).
402
- - **Checking and replaying:** `mw events status`, `mw events publish` and
403
- `mw events replay <event-id|all>` (also `GET`/`POST /api/events/publish`).
404
- - **What can receive it:** a webhook endpoint, a Hookdeck source, a Cloudflare
405
- Worker in front of a queue, or the Miadi hooks-gateway. A broker client is
406
- another publisher beside it. The events keep their names
407
- (jgwill/medicine-wheel#164, miadi-chronicle://548).
394
+ **Publishing.** Nothing leaves the machine until a destination is configured. Each destination is an
395
+ adapter with its own cursor, so one that is down never holds another back:
396
+
397
+ - any HTTP receiver: `MW_EVENTS_PUBLISH_URL`, with `_FORMAT` = `envelope`, `cloudevents` or
398
+ `cloudevents-binary`;
399
+ - a Hookdeck source: `MW_EVENTS_HOOKDECK_SOURCE_URL`;
400
+ - a Cloudflare queue: `MW_EVENTS_CF_ACCOUNT_ID`, `MW_EVENTS_CF_QUEUE_ID` and `MW_EVENTS_CF_API_TOKEN`.
401
+
402
+ The wheel sends in log order, after every write and every minute. Each adapter declares what it guarantees,
403
+ and receipts record `arrived`, `failed` (kept and retried) or `refused` (recorded and passed). Wrong
404
+ credentials and wrong addresses count as `failed`, never as `refused`.
405
+
406
+ `mw events status`, `mw events publish` and `mw events replay <event-id|all> --publisher <name>` show and
407
+ drive it, as do `GET` and `POST /api/events/publish`.
408
+
409
+ NATS JetStream and Kafka are specified, not built. Which broker to try first is weighed in
410
+ miadi-chronicle://548, `foundations/broker-adapters/` (jgwill/medicine-wheel#165).
408
411
 
409
412
  ## License
410
413
 
@@ -2,41 +2,43 @@ import { NextResponse } from "next/server";
2
2
  import { eventPublisherReport, publishPending, replayEvents } from "@/lib/event-log";
3
3
 
4
4
  const NO_PUBLISHER = {
5
- error: "No publisher is configured — the events wait in events.jsonl.",
6
- hint: "Set MW_EVENTS_PUBLISH_URL (and MW_EVENTS_PUBLISH_TOKEN) and restart the wheel.",
5
+ error: "No destination is configured — the events wait in events.jsonl.",
6
+ hint: "Set MW_EVENTS_PUBLISH_URL, MW_EVENTS_HOOKDECK_SOURCE_URL, or MW_EVENTS_CF_ACCOUNT_ID + MW_EVENTS_CF_QUEUE_ID + MW_EVENTS_CF_API_TOKEN, and restart the wheel.",
7
7
  };
8
8
 
9
9
  /**
10
- * The publisher's side of the log (#164) — episode 548's Send, Receipt,
11
- * Failure and Replay.
10
+ * The destinations' side of the log (#164, #165) — episode 548's Send,
11
+ * Receipt, Failure and Replay.
12
12
  *
13
- * GET: where the configured publisher stands (`pending`, the last event it is
14
- * done with, the last arrival, the last failure) and its latest receipts.
15
- * POST: send what waits now. `{ "replay_from": "<event id>" }` first moves the
16
- * cursor back so that event and every later one go out again, with the same
17
- * idempotency keys; `{ "replay_from": "all" }` replays the whole log.
13
+ * GET: for each destination, where it stands (`pending`, the last event it
14
+ * is done with, the last arrival, the last failure), what it promises, and
15
+ * its latest receipts.
16
+ * POST: send what waits now, to every destination. `{ "replay_from": "<event
17
+ * id>" | "all", "publisher": "<name>" }` first moves that destination's
18
+ * cursor back, so the same lines go out again with the same idempotency keys;
19
+ * the name may be left out when only one destination is configured.
18
20
  */
19
21
  export async function GET() {
20
- const report = eventPublisherReport();
21
- if (!report) return NextResponse.json(NO_PUBLISHER, { status: 409 });
22
- return NextResponse.json(report);
22
+ const publishers = eventPublisherReport();
23
+ if (!publishers) return NextResponse.json(NO_PUBLISHER, { status: 409 });
24
+ return NextResponse.json({ publishers });
23
25
  }
24
26
 
25
27
  export async function POST(request: Request) {
26
- const body = (await request.json().catch(() => ({}))) as { replay_from?: unknown };
27
- let replay: { pending: number } | null = null;
28
+ const body = (await request.json().catch(() => ({}))) as { replay_from?: unknown; publisher?: unknown };
29
+ let replay: { publisher: string; pending: number } | null = null;
28
30
  if (body?.replay_from !== undefined) {
29
31
  if (typeof body.replay_from !== "string" || !body.replay_from) {
30
32
  return NextResponse.json({ error: 'replay_from must be an event id, or "all"' }, { status: 400 });
31
33
  }
32
34
  try {
33
- replay = replayEvents(body.replay_from === "all" ? undefined : body.replay_from);
35
+ replay = replayEvents(body.replay_from === "all" ? undefined : body.replay_from, typeof body.publisher === "string" ? body.publisher : undefined);
34
36
  } catch (error: unknown) {
35
37
  return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 404 });
36
38
  }
37
39
  if (!replay) return NextResponse.json(NO_PUBLISHER, { status: 409 });
38
40
  }
39
- const result = await publishPending();
40
- if (!result) return NextResponse.json(NO_PUBLISHER, { status: 409 });
41
- return NextResponse.json({ ...(replay ? { replayed: replay.pending } : {}), ...result });
41
+ const results = await publishPending();
42
+ if (!results) return NextResponse.json(NO_PUBLISHER, { status: 409 });
43
+ return NextResponse.json({ ...(replay ? { replayed: replay } : {}), results });
42
44
  }
package/dist/cli/mw.js CHANGED
@@ -305,9 +305,9 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
305
305
  EVENTS (what happened on the wheel, kept in events.jsonl)
306
306
  mw events [--type cycle.] [--subject <id>] [--correlation <id>]
307
307
  [--since <iso>] [--limit 20] List events, newest first
308
- mw events status What the publisher still owes
309
- mw events publish Send what waits now
310
- mw events replay <event-id|all> Send again from that event
308
+ mw events status What each destination owes and promises
309
+ mw events publish Send what waits now, to every destination
310
+ mw events replay <event-id|all> [--publisher <name>] Send again from that event
311
311
  mw events sync Commit (and push) events.jsonl now
312
312
  mw events schema [--asyncapi] Print the JSON Schema (or AsyncAPI)
313
313
 
@@ -824,7 +824,7 @@ async function cmdEvents(positional, flags) {
824
824
  if (status !== 200)
825
825
  return failed(status, data);
826
826
  ppValue(data);
827
- if (data.stopped)
827
+ if (data.results?.some((r) => r.stopped))
828
828
  process.exitCode = 1;
829
829
  break;
830
830
  }
@@ -835,11 +835,12 @@ async function cmdEvents(positional, flags) {
835
835
  process.exitCode = EXIT_USAGE;
836
836
  return;
837
837
  }
838
- const { status, data } = await eventsApi('POST', '/api/events/publish', { replay_from: from });
838
+ const publisher = typeof flags['publisher'] === 'string' ? flags['publisher'] : undefined;
839
+ const { status, data } = await eventsApi('POST', '/api/events/publish', { replay_from: from, ...(publisher ? { publisher } : {}) });
839
840
  if (status !== 200)
840
841
  return failed(status, data);
841
842
  ppValue(data);
842
- if (data.stopped)
843
+ if (data.results?.some((r) => r.stopped))
843
844
  process.exitCode = 1;
844
845
  break;
845
846
  }
package/lib/event-log.ts CHANGED
@@ -14,10 +14,18 @@
14
14
  * `0.0.0.0` every wheel looks alike, and two browsers name the same wheel two
15
15
  * ways.
16
16
  *
17
- * Publishing (episode 548's Send): with `MW_EVENTS_PUBLISH_URL` set, the log is
18
- * sent there after every recorded event and every
19
- * `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute), from this wheel's
20
- * cursor, with receipts beside the log. Unset, nothing leaves the machine.
17
+ * Publishing (episode 548's Send, #165): each configured destination is a
18
+ * publisher with its own cursor and receipts beside the log, sent to after
19
+ * every recorded event and every `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one
20
+ * minute). One destination down never holds another back. Unset, nothing
21
+ * leaves the machine. The destinations:
22
+ *
23
+ * - `MW_EVENTS_PUBLISH_URL` (`_TOKEN`, `_NAME`, `_FORMAT` = envelope |
24
+ * cloudevents | cloudevents-binary): any HTTP receiver.
25
+ * - `MW_EVENTS_HOOKDECK_SOURCE_URL` (`_FORMAT`): a Hookdeck source.
26
+ * - `MW_EVENTS_CF_ACCOUNT_ID` + `MW_EVENTS_CF_QUEUE_ID` + `MW_EVENTS_CF_API_TOKEN`
27
+ * (`MW_EVENTS_CF_FORMAT` = envelope | cloudevents; `MW_EVENTS_CF_API_BASE`
28
+ * for a proxy): a Cloudflare queue.
21
29
  *
22
30
  * Carried by git, before any broker: with `MW_EVENTS_GIT_SYNC=push` (or
23
31
  * `commit`), the wheel commits `events.jsonl` — that file only — in the data
@@ -34,13 +42,17 @@
34
42
  import { buildEvent, filterEvents, type EventFilter, type WheelEvent, type WheelEventInput } from '@medicine-wheel/events';
35
43
  import { appendEvent, eventLogPath, readEventLog } from '@medicine-wheel/events/log';
36
44
  import {
45
+ cloudflareQueuesPublisher,
37
46
  drainLog,
47
+ hookdeckPublisher,
38
48
  httpPublisher,
39
49
  publisherStatus,
40
50
  readReceipts,
41
51
  rewind,
52
+ type AdapterGuarantees,
42
53
  type DrainResult,
43
54
  type Publisher,
55
+ type WireFormat,
44
56
  type PublisherStatus,
45
57
  type Receipt,
46
58
  } from '@medicine-wheel/events/publish';
@@ -89,55 +101,99 @@ export { filterEvents, type EventFilter };
89
101
 
90
102
  // ── publishing ──────────────────────────────────────────────────────────────
91
103
 
92
- /** The publisher `MW_EVENTS_PUBLISH_URL` names, or `null`: nothing leaves the machine. */
93
- export function publisherFromEnv(): Publisher | null {
94
- const url = process.env.MW_EVENTS_PUBLISH_URL?.trim();
95
- if (!url) return null;
96
- return httpPublisher({
97
- url,
98
- token: process.env.MW_EVENTS_PUBLISH_TOKEN?.trim() || undefined,
99
- name: process.env.MW_EVENTS_PUBLISH_NAME?.trim() || 'http',
104
+ const env = (key: string) => process.env[key]?.trim() || undefined;
105
+
106
+ function formatOf(key: string, allowed: readonly WireFormat[]): WireFormat {
107
+ const value = env(key);
108
+ if (!value) return 'envelope';
109
+ if ((allowed as readonly string[]).includes(value)) return value as WireFormat;
110
+ console.error(`[events] ${key}=${value} is not one of ${allowed.join(', ')}; sending the envelope`);
111
+ return 'envelope';
112
+ }
113
+
114
+ /** Every destination the environment names, each a publisher with its own cursor. None: nothing leaves the machine. */
115
+ export function publishersFromEnv(): Publisher[] {
116
+ const publishers: Publisher[] = [];
117
+ const url = env('MW_EVENTS_PUBLISH_URL');
118
+ if (url) {
119
+ publishers.push(httpPublisher({
120
+ url,
121
+ token: env('MW_EVENTS_PUBLISH_TOKEN'),
122
+ name: env('MW_EVENTS_PUBLISH_NAME') || 'http',
123
+ format: formatOf('MW_EVENTS_PUBLISH_FORMAT', ['envelope', 'cloudevents', 'cloudevents-binary']),
124
+ }));
125
+ }
126
+ const hookdeck = env('MW_EVENTS_HOOKDECK_SOURCE_URL');
127
+ if (hookdeck) {
128
+ publishers.push(hookdeckPublisher({ sourceUrl: hookdeck, format: formatOf('MW_EVENTS_HOOKDECK_FORMAT', ['envelope', 'cloudevents', 'cloudevents-binary']) }));
129
+ }
130
+ const [accountId, queueId, token] = [env('MW_EVENTS_CF_ACCOUNT_ID'), env('MW_EVENTS_CF_QUEUE_ID'), env('MW_EVENTS_CF_API_TOKEN')];
131
+ if (accountId && queueId && token) {
132
+ publishers.push(cloudflareQueuesPublisher({
133
+ accountId,
134
+ queueId,
135
+ token,
136
+ format: formatOf('MW_EVENTS_CF_FORMAT', ['envelope', 'cloudevents']) as 'envelope' | 'cloudevents',
137
+ apiBase: env('MW_EVENTS_CF_API_BASE'),
138
+ }));
139
+ } else if (accountId || queueId || token) {
140
+ console.error('[events] Cloudflare Queues needs MW_EVENTS_CF_ACCOUNT_ID, MW_EVENTS_CF_QUEUE_ID and MW_EVENTS_CF_API_TOKEN together; it is off');
141
+ }
142
+ // Two publishers under one name would share a cursor and each skip what the other sent.
143
+ const seen = new Set<string>();
144
+ return publishers.filter((p) => {
145
+ if (!seen.has(p.name)) return seen.add(p.name), true;
146
+ console.error(`[events] two publishers are named ${p.name}; the second is off`);
147
+ return false;
100
148
  });
101
149
  }
102
150
 
151
+ const publishingConfigured = () =>
152
+ Boolean(env('MW_EVENTS_PUBLISH_URL') || env('MW_EVENTS_HOOKDECK_SOURCE_URL') || env('MW_EVENTS_CF_ACCOUNT_ID'));
153
+
103
154
  // One pass at a time across every copy of this module: Next compiles
104
155
  // `instrumentation.ts` and the routes into separate bundles, and two passes
105
156
  // over the same cursor would send the same event twice.
106
157
  const STATE = Symbol.for('@medicine-wheel/app.event-publisher');
107
- type PublishState = { running?: Promise<DrainResult | null>; again?: boolean; timer?: ReturnType<typeof setInterval>; down?: boolean };
158
+ type PublishState = { running?: Promise<DrainResult[] | null>; again?: boolean; timer?: ReturnType<typeof setInterval>; down?: Set<string> };
108
159
  const state: PublishState = ((globalThis as Record<symbol, PublishState>)[STATE] ??= {});
109
160
 
110
- async function pass(): Promise<DrainResult | null> {
111
- const publisher = publisherFromEnv();
112
- if (!publisher) return null;
113
- try {
114
- const result = await drainLog(eventLogFile(), publisher);
115
- // A destination that stays down is one line when it goes, one when it returns.
116
- if (result.stopped && !state.down) {
117
- console.error(`[events] publishing to ${publisher.name} stopped: ${result.error} — ${result.pending} event(s) waiting`);
118
- } else if (!result.stopped && state.down) {
119
- console.log(`[events] publishing to ${publisher.name} resumed; ${result.arrived} event(s) arrived`);
161
+ async function pass(): Promise<DrainResult[] | null> {
162
+ const publishers = publishersFromEnv();
163
+ if (!publishers.length) return null;
164
+ const down = (state.down ??= new Set());
165
+ const results: DrainResult[] = [];
166
+ for (const publisher of publishers) {
167
+ try {
168
+ const result = await drainLog(eventLogFile(), publisher);
169
+ // A destination that stays down is one line when it goes, one when it returns.
170
+ if (result.stopped && !down.has(publisher.name)) {
171
+ console.error(`[events] publishing to ${publisher.name} stopped: ${result.error} — ${result.pending} event(s) waiting`);
172
+ down.add(publisher.name);
173
+ } else if (!result.stopped && down.has(publisher.name)) {
174
+ console.log(`[events] publishing to ${publisher.name} resumed; ${result.arrived} event(s) arrived`);
175
+ down.delete(publisher.name);
176
+ }
177
+ results.push(result);
178
+ } catch (error) {
179
+ console.error(`[events] publishing to ${publisher.name} failed: ${(error as Error)?.message ?? String(error)}`);
120
180
  }
121
- state.down = result.stopped;
122
- return result;
123
- } catch (error) {
124
- console.error(`[events] publishing pass failed: ${(error as Error)?.message ?? String(error)}`);
125
- return null;
126
181
  }
182
+ return results;
127
183
  }
128
184
 
129
- /** Send what waits. A pass already running is joined, and runs once more after it for what arrived meanwhile. */
130
- export function publishPending(): Promise<DrainResult | null> {
185
+ /** Send what waits, to every destination. A pass already running is joined, and runs once more after it for what arrived meanwhile. */
186
+ export function publishPending(): Promise<DrainResult[] | null> {
131
187
  if (state.running) {
132
188
  state.again = true;
133
189
  return state.running;
134
190
  }
135
- const run = pass().then(async (result) => {
191
+ const run = pass().then(async (results) => {
136
192
  while (state.again) {
137
193
  state.again = false;
138
- result = (await pass()) ?? result;
194
+ results = (await pass()) ?? results;
139
195
  }
140
- return result;
196
+ return results;
141
197
  }).finally(() => {
142
198
  if (state.running === run) state.running = undefined;
143
199
  });
@@ -145,38 +201,58 @@ export function publishPending(): Promise<DrainResult | null> {
145
201
  return run;
146
202
  }
147
203
 
148
- /** After a recorded event: send it soon, in the background. Nothing when no publisher is set. */
204
+ /** After a recorded event: send it soon, in the background. Nothing when no destination is set. */
149
205
  export function kickPublisher(): void {
150
- if (!process.env.MW_EVENTS_PUBLISH_URL?.trim()) return;
206
+ if (!publishingConfigured()) return;
151
207
  void publishPending();
152
208
  }
153
209
 
154
210
  /**
155
211
  * Start the publishing clock: one pass now, then one every
156
212
  * `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute). Called once from
157
- * `instrumentation.ts`; a second call is a no-op, and without a publisher
213
+ * `instrumentation.ts`; a second call is a no-op, and without a destination
158
214
  * there is nothing to send.
159
215
  */
160
216
  export function startEventPublishing(intervalMs = Number(process.env.MW_EVENTS_PUBLISH_INTERVAL_MS) || 60_000): void {
161
- if (state.timer || !publisherFromEnv()) return;
217
+ if (state.timer || !publishersFromEnv().length) return;
162
218
  state.timer = setInterval(() => { void publishPending(); }, intervalMs);
163
219
  state.timer.unref?.();
164
220
  void publishPending();
165
221
  }
166
222
 
167
- /** Replay: the next pass sends `fromEventId` and everything after it again (the whole log without one). */
168
- export function replayEvents(fromEventId?: string): { pending: number } | null {
169
- const publisher = publisherFromEnv();
170
- if (!publisher) return null;
171
- return rewind(eventLogFile(), publisher.name, fromEventId);
223
+ /**
224
+ * Replay: the named publisher's next pass sends `fromEventId` and everything
225
+ * after it again (the whole log without one). With one destination the name
226
+ * may be left out. `null`: no destination is configured.
227
+ */
228
+ export function replayEvents(fromEventId?: string, publisherName?: string): { publisher: string; pending: number } | null {
229
+ const publishers = publishersFromEnv();
230
+ if (!publishers.length) return null;
231
+ const publisher = publisherName ? publishers.find((p) => p.name === publisherName) : publishers.length === 1 ? publishers[0] : undefined;
232
+ if (!publisher) {
233
+ throw new Error(publisherName
234
+ ? `No publisher named ${publisherName}; configured: ${publishers.map((p) => p.name).join(', ')}`
235
+ : `Name the publisher to replay: ${publishers.map((p) => p.name).join(', ')}`);
236
+ }
237
+ return { publisher: publisher.name, ...rewind(eventLogFile(), publisher.name, fromEventId) };
238
+ }
239
+
240
+ export interface PublisherReport {
241
+ status: PublisherStatus;
242
+ guarantees?: AdapterGuarantees;
243
+ receipts: Receipt[];
172
244
  }
173
245
 
174
- /** Where the configured publisher stands, and its latest receipts. */
175
- export function eventPublisherReport(limit = 20): { publisher: PublisherStatus; receipts: Receipt[] } | null {
176
- const publisher = publisherFromEnv();
177
- if (!publisher) return null;
246
+ /** Where each destination stands, what it promises, and its latest receipts. `null`: none is configured. */
247
+ export function eventPublisherReport(limit = 20): PublisherReport[] | null {
248
+ const publishers = publishersFromEnv();
249
+ if (!publishers.length) return null;
178
250
  const file = eventLogFile();
179
- return { publisher: publisherStatus(file, publisher.name), receipts: readReceipts(file, publisher.name).slice(-limit).reverse() };
251
+ return publishers.map((p) => ({
252
+ status: publisherStatus(file, p.name),
253
+ ...(p.guarantees ? { guarantees: p.guarantees } : {}),
254
+ receipts: readReceipts(file, p.name).slice(-limit).reverse(),
255
+ }));
180
256
  }
181
257
 
182
258
  // ── carried by git ──────────────────────────────────────────────────────────
@@ -261,16 +337,16 @@ export function eventGitStatus(): { mode: GitSyncMode; last?: GitState['last'] }
261
337
  return { mode: gitSyncMode(), ...(gitState.last ? { last: gitState.last } : {}) };
262
338
  }
263
339
 
264
- /** What `/api/health` reports: how many events, the last one, what the publisher owes, how the git carriage stands. */
265
- export function eventLogStatus(): { count: number; last_at?: string; publisher?: PublisherStatus; git?: ReturnType<typeof eventGitStatus> } | { error: string } {
340
+ /** What `/api/health` reports: how many events, the last one, what each destination owes, how the git carriage stands. */
341
+ export function eventLogStatus(): { count: number; last_at?: string; publishers?: PublisherStatus[]; git?: ReturnType<typeof eventGitStatus> } | { error: string } {
266
342
  try {
267
343
  const events = readEvents();
268
344
  const last = events[events.length - 1];
269
- const publisher = publisherFromEnv();
345
+ const publishers = publishersFromEnv();
270
346
  return {
271
347
  count: events.length,
272
348
  ...(last ? { last_at: last.observed_at } : {}),
273
- ...(publisher ? { publisher: publisherStatus(eventLogFile(), publisher.name) } : {}),
349
+ ...(publishers.length ? { publishers: publishers.map((p) => publisherStatus(eventLogFile(), p.name)) } : {}),
274
350
  ...(gitSyncMode() !== 'off' ? { git: eventGitStatus() } : {}),
275
351
  };
276
352
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
5
5
  "bin": {
6
6
  "mw": "dist/cli/mw.js",
@@ -92,33 +92,33 @@
92
92
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
93
93
  },
94
94
  "dependencies": {
95
- "@medicine-wheel/ceremonial-diary": "^0.19.0",
96
- "@medicine-wheel/ceremony-protocol": "^0.19.0",
97
- "@medicine-wheel/community-identity": "^0.19.0",
98
- "@medicine-wheel/community-review": "^0.19.0",
99
- "@medicine-wheel/consent-lifecycle": "^0.19.0",
100
- "@medicine-wheel/creative-orientation": "^0.19.0",
101
- "@medicine-wheel/data-store": "^0.19.0",
102
- "@medicine-wheel/data-store-postgres": "^0.19.0",
103
- "@medicine-wheel/events": "^0.19.0",
104
- "@medicine-wheel/fire-keeper": "^0.19.0",
105
- "@medicine-wheel/github-ceremony": "^0.19.0",
106
- "@medicine-wheel/graph-viz": "^0.19.0",
107
- "@medicine-wheel/honcho": "^0.19.0",
108
- "@medicine-wheel/importance-unit": "^0.19.0",
109
- "@medicine-wheel/mcp": "^4.19.0",
110
- "@medicine-wheel/memory": "^0.19.0",
111
- "@medicine-wheel/narrative-cluster": "^0.19.0",
112
- "@medicine-wheel/narrative-engine": "^0.19.0",
113
- "@medicine-wheel/ontology-core": "^0.19.0",
114
- "@medicine-wheel/perception-layer": "^0.19.0",
115
- "@medicine-wheel/prompt-decomposition": "^0.19.0",
116
- "@medicine-wheel/relational-index": "^0.19.0",
117
- "@medicine-wheel/relational-query": "^0.19.0",
118
- "@medicine-wheel/session-reader": "^0.19.0",
119
- "@medicine-wheel/storage-provider": "^0.19.0",
120
- "@medicine-wheel/transformation-tracker": "^0.19.0",
121
- "@medicine-wheel/ui-components": "^0.19.0",
95
+ "@medicine-wheel/ceremonial-diary": "^0.20.0",
96
+ "@medicine-wheel/ceremony-protocol": "^0.20.0",
97
+ "@medicine-wheel/community-identity": "^0.20.0",
98
+ "@medicine-wheel/community-review": "^0.20.0",
99
+ "@medicine-wheel/consent-lifecycle": "^0.20.0",
100
+ "@medicine-wheel/creative-orientation": "^0.20.0",
101
+ "@medicine-wheel/data-store": "^0.20.0",
102
+ "@medicine-wheel/data-store-postgres": "^0.20.0",
103
+ "@medicine-wheel/events": "^0.20.0",
104
+ "@medicine-wheel/fire-keeper": "^0.20.0",
105
+ "@medicine-wheel/github-ceremony": "^0.20.0",
106
+ "@medicine-wheel/graph-viz": "^0.20.0",
107
+ "@medicine-wheel/honcho": "^0.20.0",
108
+ "@medicine-wheel/importance-unit": "^0.20.0",
109
+ "@medicine-wheel/mcp": "^4.20.0",
110
+ "@medicine-wheel/memory": "^0.20.0",
111
+ "@medicine-wheel/narrative-cluster": "^0.20.0",
112
+ "@medicine-wheel/narrative-engine": "^0.20.0",
113
+ "@medicine-wheel/ontology-core": "^0.20.0",
114
+ "@medicine-wheel/perception-layer": "^0.20.0",
115
+ "@medicine-wheel/prompt-decomposition": "^0.20.0",
116
+ "@medicine-wheel/relational-index": "^0.20.0",
117
+ "@medicine-wheel/relational-query": "^0.20.0",
118
+ "@medicine-wheel/session-reader": "^0.20.0",
119
+ "@medicine-wheel/storage-provider": "^0.20.0",
120
+ "@medicine-wheel/transformation-tracker": "^0.20.0",
121
+ "@medicine-wheel/ui-components": "^0.20.0",
122
122
  "@miadi/episode-vessel": "^0.2.1",
123
123
  "@neondatabase/serverless": "^0.10.0",
124
124
  "@xyflow/react": "^12.3.0",