@medicine-wheel/app 0.12.0 → 0.14.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
@@ -30,7 +30,14 @@ storage alongside its composable libraries.
30
30
  ├── @medicine-wheel/relational-query ← Query, traversal & audit
31
31
  ├── @medicine-wheel/prompt-decomposition ← Intent extraction & PDE
32
32
  ├── @medicine-wheel/ui-components ← React components
33
- ├── @medicine-wheel/data-store ← Shared data access
33
+ ├── @medicine-wheel/data-store ← Shared data access (Redis)
34
+ ├── @medicine-wheel/data-store-postgres ← PostgreSQL / Neon provider scaffold
35
+ ├── @medicine-wheel/storage-provider ← Canonical persistence contract (JSONL ⇄ Neon)
36
+ ├── @medicine-wheel/infra ← Hosts, tenants, services, port bindings as facets
37
+ ├── @medicine-wheel/perception-layer ← Witness recordings as typed perceptual events
38
+ ├── @medicine-wheel/narrative-cluster ← Events → clusters → beats → Film Edit Brief
39
+ ├── @medicine-wheel/ceremonial-diary ← A participant's voice across the Five Phases
40
+ ├── @medicine-wheel/github-ceremony ← GitHub webhooks as ceremony beads
34
41
  └── @medicine-wheel/session-reader ← Session event data reader
35
42
 
36
43
  @medicine-wheel/creative-orientation ← The question asked before the work
@@ -134,6 +141,41 @@ Session event reader — JSONL parsing, session summaries, analytics extraction,
134
141
 
135
142
  - **Dependencies:** None (Node.js built-ins only)
136
143
 
144
+ ### [@medicine-wheel/storage-provider](src/storage-provider)
145
+ The canonical persistence contract. One `StorageProvider` interface with two first-class equals behind it — a JSONL file store for a project's `.mw/store/` and a Neon (Postgres) store — plus typed relational refusals and the capture, inquiry-weave and plan-perspective registries. The app, the MCP server and every package that persists go through this door.
146
+
147
+ - **Dependencies:** `@medicine-wheel/ontology-core`, `@neondatabase/serverless`, `@upstash/redis`
148
+
149
+ ### [@medicine-wheel/data-store-postgres](src/data-store-postgres)
150
+ Minimal `pg` scaffold — shared pool management and provider-ready storage entrypoints. Explicitly not a competing provider architecture; `storage-provider` owns the contract.
151
+
152
+ - **Dependencies:** `@medicine-wheel/ontology-core`, `pg`
153
+
154
+ ### [@medicine-wheel/infra](src/infra)
155
+ Typed infrastructure facets — hosts, tenants, services and port bindings — keyed by relational node id, with `detectPortConflicts` over declared ∪ observed state. Types plus one pure function: zero I/O, zero persistence. A running service is registered as a `knowledge` node carrying `metadata.kind: "service"`, never as a new node type.
156
+
157
+ - **Dependencies:** `@medicine-wheel/ontology-core`, `zod`
158
+
159
+ ### [@medicine-wheel/perception-layer](src/perception-layer)
160
+ Eyes and ears of agent-supported film production — witness a belt-device recording as typed perceptual events and seed a production knowledge graph.
161
+
162
+ - **Dependencies:** `@medicine-wheel/ontology-core`
163
+
164
+ ### [@medicine-wheel/narrative-cluster](src/narrative-cluster)
165
+ Narrative Cluster Processor — turn witnessed perceptual events or rushes into thematic clusters, direction-aligned narrative beats, and a Film Edit Brief with EDL markers.
166
+
167
+ - **Dependencies:** `@medicine-wheel/ontology-core`
168
+
169
+ ### [@medicine-wheel/ceremonial-diary](src/ceremonial-diary)
170
+ A participant's voice across the Five-Phase ceremonial methodology — intention, observation and reflection entries with pattern detection, statistics, markdown export, and optional projection into the chronicle wheel.
171
+
172
+ - **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/storage-provider`
173
+
174
+ ### [@medicine-wheel/github-ceremony](src/github-ceremony)
175
+ Witness GitHub webhook events (issues, pull requests, merges, commits) through a ceremonial lens and record them as relational ceremony beads. Pure, framework-free functions over a parsed payload and a storage provider.
176
+
177
+ - **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/storage-provider`
178
+
137
179
  ### [@medicine-wheel/creative-orientation](src/creative-orientation)
138
180
  The orientation question, asked before the work: *is there a prior state you are restoring?* Yes — this is a fire, route to gap analysis. No — you are creating, route to structural tension. Reads the claim the caller supplies; advises where phrasing and situation disagree, and never refuses.
139
181
 
@@ -173,6 +215,13 @@ RISE framework specifications are in [`rispecs/`](rispecs/). Start with [`medici
173
215
  | graph-viz | [graph-viz.spec.md](rispecs/graph-viz.spec.md) |
174
216
  | relational-query | [relational-query.spec.md](rispecs/relational-query.spec.md) |
175
217
  | prompt-decomposition | [prompt-decomposition.spec.md](rispecs/prompt-decomposition.spec.md) |
218
+ | storage-provider | [storage-provider.spec.md](rispecs/storage-provider.spec.md) |
219
+ | data-store | [data-store.spec.md](rispecs/data-store.spec.md) |
220
+ | data-store-postgres | [data-store-postgres.spec.md](rispecs/data-store-postgres.spec.md) |
221
+ | perception-layer | [perception-layer.spec.md](rispecs/perception-layer.spec.md) |
222
+ | narrative-cluster | [narrative-cluster.spec.md](rispecs/narrative-cluster.spec.md) |
223
+ | session-reader | [session-reader.spec.md](rispecs/session-reader.spec.md) |
224
+ | infra | [infrastructure-topology-ui.spec.md](rispecs/infrastructure-topology-ui.spec.md) |
176
225
  | ui-components | [ui-components.spec.md](rispecs/ui-components.spec.md) |
177
226
  | data-store | [data-store.spec.md](rispecs/data-store.spec.md) |
178
227
  | session-reader | [session-reader.spec.md](rispecs/session-reader.spec.md) |
@@ -224,4 +273,4 @@ available via a local `MW_MCP_PATH`.
224
273
 
225
274
  ## License
226
275
 
227
- MIT see [LICENSE](LICENSE)
276
+ Indigenous Knowledge Stewardship License (IKSL) v1.0 MIT Derived see [LICENSE](LICENSE)
@@ -1,7 +1,10 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { parseLimit } from "@/lib/api-paging";
3
3
  import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
4
- import { ceremonyBelongsToEpisode } from "@/lib/ceremony-response";
4
+ import { ceremonyBelongsToEpisode, ceremonyEpisodePath } from "@/lib/ceremony-response";
5
+
6
+ /** Episode directory names are `YYYY-MM-DD-episode-NNN-slug`; nothing else may bind. */
7
+ const EPISODE_PATH = /^\d{4}-\d{2}-\d{2}-episode-\d{3,}-[a-z0-9-]+$/;
5
8
 
6
9
  export async function GET(request: Request) {
7
10
  try {
@@ -9,6 +12,8 @@ export async function GET(request: Request) {
9
12
  const direction = searchParams.get("direction");
10
13
  const type = searchParams.get("type");
11
14
  const episodePath = searchParams.get("episode_path");
15
+ const circleId = searchParams.get("circle_id");
16
+ const closes = searchParams.get("closes");
12
17
 
13
18
  const limit = parseLimit(searchParams.get("limit"));
14
19
  if (limit instanceof NextResponse) return limit;
@@ -41,6 +46,15 @@ export async function GET(request: Request) {
41
46
  );
42
47
  }
43
48
 
49
+ if (circleId) {
50
+ ceremonies = ceremonies.filter((c) => c.circle_id === circleId);
51
+ }
52
+
53
+ // `?closes=<opening id>` answers "is this ceremony closed, and by which record".
54
+ if (closes) {
55
+ ceremonies = ceremonies.filter((c) => c.closes === closes);
56
+ }
57
+
44
58
  const matched = ceremonies.length;
45
59
  if (limit !== null && ceremonies.length > limit) {
46
60
  ceremonies = ceremonies.slice(0, limit);
@@ -53,7 +67,7 @@ export async function GET(request: Request) {
53
67
  // `total` is the whole store, `matched` what the filters selected. When
54
68
  // count < matched the caller holds a page and can now see that it does.
55
69
  total,
56
- ...(direction || type || episodePath ? { matched } : {}),
70
+ ...(direction || type || episodePath || circleId || closes ? { matched } : {}),
57
71
  truncated: ceremonies.length < matched,
58
72
  });
59
73
  } catch (error: unknown) {
@@ -66,7 +80,47 @@ export async function POST(request: Request) {
66
80
  try {
67
81
  const store = await createProvider();
68
82
  const body = await request.json();
69
-
83
+
84
+ // Typed episode binding (0.14.0). A caller may still pass the legacy JSON
85
+ // string in `research_context`; it is lifted into the typed fields so every
86
+ // reader sees one shape. When both are given the typed fields win.
87
+ const legacy = ceremonyEpisodePath(body);
88
+ const episodePath: string | undefined =
89
+ typeof body.episode_path === "string" && body.episode_path ? body.episode_path : legacy;
90
+ if (episodePath !== undefined && !EPISODE_PATH.test(episodePath)) {
91
+ return NextResponse.json(
92
+ { error: `Invalid episode_path: ${episodePath}`, hint: "YYYY-MM-DD-episode-NNN-slug" },
93
+ { status: 400 },
94
+ );
95
+ }
96
+ const fromName = episodePath ? Number(episodePath.match(/-episode-(\d+)-/)?.[1]) : NaN;
97
+ const episodeNumber: number | undefined =
98
+ typeof body.episode_number === "number" ? body.episode_number : Number.isFinite(fromName) ? fromName : undefined;
99
+
100
+ if (body.closes !== undefined) {
101
+ if (typeof body.closes !== "string" || !body.closes) {
102
+ return NextResponse.json({ error: "closes must be a ceremony id" }, { status: 400 });
103
+ }
104
+ if (!(await store.getCeremony(body.closes))) {
105
+ return NextResponse.json(
106
+ { error: `Cannot close a ceremony that does not exist: ${body.closes}` },
107
+ { status: 404 },
108
+ );
109
+ }
110
+ }
111
+ if (body.circle_id !== undefined) {
112
+ if (typeof body.circle_id !== "string" || !body.circle_id) {
113
+ return NextResponse.json({ error: "circle_id must be a node id" }, { status: 400 });
114
+ }
115
+ const circle = await store.getNode(body.circle_id);
116
+ if (!circle) {
117
+ return NextResponse.json(
118
+ { error: `Cannot hold a ceremony in a circle that does not exist: ${body.circle_id}`, hint: "Create the circle node first on /nodes." },
119
+ { status: 404 },
120
+ );
121
+ }
122
+ }
123
+
70
124
  const ceremony = {
71
125
  id: body.id || crypto.randomUUID(),
72
126
  type: body.type,
@@ -74,10 +128,16 @@ export async function POST(request: Request) {
74
128
  participants: body.participants ?? [],
75
129
  medicines_used: body.medicines_used ?? [],
76
130
  intentions: body.intentions ?? [],
77
- timestamp: new Date().toISOString(),
131
+ timestamp: typeof body.timestamp === "string" && body.timestamp ? body.timestamp : new Date().toISOString(),
78
132
  research_context: body.research_context,
133
+ ...(Array.isArray(body.relations_honored) ? { relations_honored: body.relations_honored } : {}),
134
+ ...(episodePath ? { episode_path: episodePath } : {}),
135
+ ...(episodeNumber !== undefined ? { episode_number: episodeNumber } : {}),
136
+ ...(typeof body.source === "string" && body.source ? { source: body.source } : {}),
137
+ ...(typeof body.closes === "string" ? { closes: body.closes } : {}),
138
+ ...(typeof body.circle_id === "string" ? { circle_id: body.circle_id } : {}),
79
139
  };
80
-
140
+
81
141
  await store.logCeremony(ceremony);
82
142
  return NextResponse.json({ success: true, ceremony, provider: detectProvider() }, { status: 201 });
83
143
  } catch (error: unknown) {
@@ -0,0 +1,31 @@
1
+ import { NextResponse } from "next/server";
2
+ import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+
4
+ type RouteContext = { params: Promise<{ id: string }> };
5
+
6
+ export async function GET(_request: Request, context: RouteContext) {
7
+ const { id } = await context.params;
8
+ try {
9
+ const store = await createProvider();
10
+ const entry = await store.getDiaryEntry(id);
11
+ if (!entry) return NextResponse.json({ error: `No diary entry with id ${id}` }, { status: 404 });
12
+ return NextResponse.json({ entry, provider: detectProvider() });
13
+ } catch (error: unknown) {
14
+ const message = error instanceof Error ? error.message : String(error);
15
+ return NextResponse.json({ error: message }, { status: 500 });
16
+ }
17
+ }
18
+
19
+ export async function DELETE(_request: Request, context: RouteContext) {
20
+ const { id } = await context.params;
21
+ try {
22
+ const store = await createProvider();
23
+ const entry = await store.getDiaryEntry(id);
24
+ if (!entry) return NextResponse.json({ error: `No diary entry with id ${id}` }, { status: 404 });
25
+ await store.deleteDiaryEntry(id);
26
+ return NextResponse.json({ success: true, deleted: { id }, provider: detectProvider() });
27
+ } catch (error: unknown) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ return NextResponse.json({ error: message }, { status: 500 });
30
+ }
31
+ }
@@ -0,0 +1,130 @@
1
+ import { NextResponse } from "next/server";
2
+ import { parseLimit } from "@/lib/api-paging";
3
+ import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
4
+ import type { DiaryEntryFilters, DiaryEntryRecord } from "@medicine-wheel/storage-provider";
5
+ import { createDiaryEntry } from "@medicine-wheel/ceremonial-diary";
6
+
7
+ /**
8
+ * The ceremonial diary door (0.14.0).
9
+ *
10
+ * The storage provider has carried diary records since 0.6 (`registerDiaryEntry`,
11
+ * `listDiaryEntries`); no route exposed them, so every consumer that wanted a
12
+ * diary kept its own jsonl beside the wheel. This door reads and writes the
13
+ * same collection the MCP and the provider share.
14
+ *
15
+ * A diary entry is a participant's voice inside a ceremony. It may name the
16
+ * ceremony it belongs to (`ceremony_id`, carried in metadata) and the chronicle
17
+ * episode it writes into (`chronicle`, `chronicle:<episode-folder>`); both are
18
+ * optional — the diary can speak whether or not it writes into an episode.
19
+ */
20
+
21
+ const PHASES = ["miigwechiwendam", "nindokendaan", "ningwaab", "nindoodam", "migwech"] as const;
22
+ const ENTRY_TYPES = ["intention", "observation", "hypothesis", "data", "synthesis", "action", "reflection", "learning"] as const;
23
+
24
+ type Phase = DiaryEntryRecord["phase"];
25
+ type EntryType = DiaryEntryRecord["entryType"];
26
+
27
+ function ceremonyOf(entry: DiaryEntryRecord): string | undefined {
28
+ const value = entry.metadata?.ceremony_id;
29
+ return typeof value === "string" && value ? value : undefined;
30
+ }
31
+
32
+ export async function GET(request: Request) {
33
+ try {
34
+ const { searchParams } = new URL(request.url);
35
+ const limit = parseLimit(searchParams.get("limit"));
36
+ if (limit instanceof NextResponse) return limit;
37
+
38
+ const participant = searchParams.get("participant") ?? undefined;
39
+ const phase = searchParams.get("phase") ?? undefined;
40
+ const entryType = searchParams.get("entryType") ?? searchParams.get("entry_type") ?? undefined;
41
+ const chronicle = searchParams.get("chronicle") ?? undefined;
42
+ const ceremonyId = searchParams.get("ceremony_id") ?? undefined;
43
+ const tags = searchParams.getAll("tag");
44
+
45
+ if (phase && !PHASES.includes(phase as Phase)) {
46
+ return NextResponse.json({ error: `Invalid phase: ${phase}`, hint: PHASES.join(", ") }, { status: 400 });
47
+ }
48
+ if (entryType && !ENTRY_TYPES.includes(entryType as EntryType)) {
49
+ return NextResponse.json({ error: `Invalid entryType: ${entryType}`, hint: ENTRY_TYPES.join(", ") }, { status: 400 });
50
+ }
51
+
52
+ const filters: DiaryEntryFilters = {
53
+ ...(participant ? { participant } : {}),
54
+ ...(phase ? { phase: phase as Phase } : {}),
55
+ ...(entryType ? { entryType: entryType as EntryType } : {}),
56
+ ...(tags.length ? { tags } : {}),
57
+ };
58
+
59
+ const store = await createProvider();
60
+ const all = await store.listDiaryEntries(filters);
61
+ let entries = all;
62
+ if (chronicle) entries = entries.filter((e) => e.chronicle === chronicle);
63
+ if (ceremonyId) entries = entries.filter((e) => ceremonyOf(e) === ceremonyId);
64
+ const matched = entries.length;
65
+ if (limit !== null && entries.length > limit) entries = entries.slice(0, limit);
66
+
67
+ return NextResponse.json({
68
+ entries,
69
+ provider: detectProvider(),
70
+ count: entries.length,
71
+ total: all.length,
72
+ ...(chronicle || ceremonyId ? { matched } : {}),
73
+ truncated: entries.length < matched,
74
+ });
75
+ } catch (error: unknown) {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ return NextResponse.json({ error: message }, { status: 500 });
78
+ }
79
+ }
80
+
81
+ export async function POST(request: Request) {
82
+ try {
83
+ let body: Record<string, unknown>;
84
+ try {
85
+ body = (await request.json()) as Record<string, unknown>;
86
+ } catch {
87
+ return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
88
+ }
89
+ const { participant, phase, entryType, content } = body;
90
+ if (typeof participant !== "string" || !participant.trim()) {
91
+ return NextResponse.json({ error: "participant is required" }, { status: 400 });
92
+ }
93
+ if (typeof phase !== "string" || !PHASES.includes(phase as Phase)) {
94
+ return NextResponse.json({ error: `phase must be one of: ${PHASES.join(", ")}` }, { status: 400 });
95
+ }
96
+ if (typeof entryType !== "string" || !ENTRY_TYPES.includes(entryType as EntryType)) {
97
+ return NextResponse.json({ error: `entryType must be one of: ${ENTRY_TYPES.join(", ")}` }, { status: 400 });
98
+ }
99
+ if (typeof content !== "string" || !content.trim()) {
100
+ return NextResponse.json({ error: "content is required" }, { status: 400 });
101
+ }
102
+ const metadata: Record<string, unknown> =
103
+ body.metadata && typeof body.metadata === "object" ? { ...(body.metadata as Record<string, unknown>) } : {};
104
+ const store = await createProvider();
105
+ if (typeof body.ceremony_id === "string" && body.ceremony_id) {
106
+ if (!(await store.getCeremony(body.ceremony_id))) {
107
+ return NextResponse.json(
108
+ { error: `Cannot write into a ceremony that does not exist: ${body.ceremony_id}` },
109
+ { status: 404 },
110
+ );
111
+ }
112
+ metadata.ceremony_id = body.ceremony_id;
113
+ }
114
+ const entry = await createDiaryEntry(store, {
115
+ ...(typeof body.id === "string" && body.id ? { id: body.id } : {}),
116
+ ...(typeof body.timestamp === "string" && body.timestamp ? { timestamp: body.timestamp } : {}),
117
+ participant: participant.trim(),
118
+ phase: phase as Phase,
119
+ entryType: entryType as EntryType,
120
+ content,
121
+ metadata,
122
+ ...(typeof body.agent === "string" && body.agent ? { agent: body.agent } : {}),
123
+ ...(typeof body.chronicle === "string" && body.chronicle ? { chronicle: body.chronicle } : {}),
124
+ });
125
+ return NextResponse.json({ success: true, entry, provider: detectProvider() }, { status: 201 });
126
+ } catch (error: unknown) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ return NextResponse.json({ error: message }, { status: 500 });
129
+ }
130
+ }
@@ -0,0 +1,55 @@
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { getBeat, witnessBeat } from "@/lib/store";
4
+
5
+ type RouteContext = { params: Promise<{ id: string }> };
6
+
7
+ /**
8
+ * One beat, by id — and the witnessing door.
9
+ *
10
+ * In a talking circle a beat is one person's turn. Those who held the circle
11
+ * while it was spoken are its witnesses (`witnesses[]`, 0.14.0). PATCH adds
12
+ * witnesses (a set: repeating a name changes nothing) and may name the
13
+ * `speaker` when the beat was authored without one. Nothing else on a beat is
14
+ * editable here: what was said is what was said.
15
+ */
16
+ const BeatPatchSchema = z
17
+ .object({
18
+ witnesses: z.array(z.string().trim().min(1)).optional(),
19
+ speaker: z.string().trim().min(1).optional(),
20
+ })
21
+ .strict()
22
+ .refine((patch) => patch.witnesses !== undefined || patch.speaker !== undefined, {
23
+ message: "Provide witnesses to add, a speaker, or both.",
24
+ });
25
+
26
+ function notFound(id: string) {
27
+ return NextResponse.json({ error: `No beat with id ${id}` }, { status: 404 });
28
+ }
29
+
30
+ export async function GET(_request: Request, context: RouteContext) {
31
+ const { id } = await context.params;
32
+ const beat = getBeat(id);
33
+ if (!beat) return notFound(id);
34
+ return NextResponse.json(beat);
35
+ }
36
+
37
+ export async function PATCH(request: Request, context: RouteContext) {
38
+ const { id } = await context.params;
39
+ let body: unknown;
40
+ try {
41
+ body = await request.json();
42
+ } catch {
43
+ return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
44
+ }
45
+ const parsed = BeatPatchSchema.safeParse(body);
46
+ if (!parsed.success) {
47
+ return NextResponse.json(
48
+ { error: "Invalid patch", issues: parsed.error.issues },
49
+ { status: 400 },
50
+ );
51
+ }
52
+ const beat = witnessBeat(id, parsed.data);
53
+ if (!beat) return notFound(id);
54
+ return NextResponse.json(beat);
55
+ }
@@ -30,6 +30,8 @@ export async function POST(request: Request) {
30
30
  parent_beat_id: body.parent_beat_id,
31
31
  sub_beats: body.sub_beats,
32
32
  origin: body.origin,
33
+ speaker: body.speaker,
34
+ witnesses: Array.isArray(body.witnesses) ? body.witnesses : undefined,
33
35
  });
34
36
  // Warnings ride on the created beat rather than replacing it, so clients
35
37
  // that read the beat back by id keep working while advisory findings stop
@@ -8,6 +8,16 @@ import { extractCeremonyLogs } from "@/lib/ceremony-response";
8
8
  import { relativeTime, absoluteTime } from "@/lib/format-time";
9
9
  import { toast } from "sonner";
10
10
 
11
+ // Static tables — module scope so they are one object for the life of the page,
12
+ // not re-created on every render. `PHASE_FOR_DIRECTION` used to be declared inside
13
+ // the component and read by a `useMemo` that did not list it: correct only by
14
+ // accident of it never changing, and flagged by react-hooks/exhaustive-deps.
15
+ const directions: DirectionName[] = ["east", "south", "west", "north"];
16
+ const cTypes: CeremonyType[] = ["smudging", "talking_circle", "spirit_feeding", "opening", "closing"];
17
+ const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
18
+ const PHASE_FOR_DIRECTION: Record<DirectionName, string> = { east: "opening", south: "council", west: "integration", north: "closure" };
19
+
20
+
11
21
  const dirVar = (d: string) => `var(--mw-${d})`;
12
22
  const dirInk = (d: string) => `var(--mw-${d}-ink)`;
13
23
 
@@ -64,15 +74,10 @@ function CeremoniesContent() {
64
74
  } else { toast.error("Could not save the ceremony — check the form and try again"); }
65
75
  }
66
76
 
67
- const directions: DirectionName[] = ["east", "south", "west", "north"];
68
- const cTypes: CeremonyType[] = ["smudging", "talking_circle", "spirit_feeding", "opening", "closing"];
69
- const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
70
-
71
- const phaseMap: Record<DirectionName, string> = { east: "opening", south: "council", west: "integration", north: "closure" };
72
77
  const currentPhase = useMemo(() => {
73
78
  if (ceremonies.length === 0) return "opening";
74
79
  const latest = [...ceremonies].sort((a, b) => b.timestamp.localeCompare(a.timestamp))[0];
75
- return phaseMap[latest.direction] ?? "opening";
80
+ return PHASE_FOR_DIRECTION[latest.direction] ?? "opening";
76
81
  }, [ceremonies]);
77
82
 
78
83
  const phaseFramings: Record<string, string> = {
@@ -15,9 +15,9 @@ import { toast } from "sonner";
15
15
  // Storage returns description at the top level; legacy nodes carried it in metadata.
16
16
  type NodeRecord = RelationalNode & { description?: string };
17
17
 
18
- const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge"];
18
+ const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge", "circle"];
19
19
  const DIRECTIONS: DirectionName[] = ["east", "south", "west", "north"];
20
- const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚" };
20
+ const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚", circle: "⭕" };
21
21
  const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
22
22
 
23
23
  function describeNode(node: NodeRecord): string {
@@ -65,7 +65,7 @@ Object.defineProperty(exports, "actForDirection", { enumerable: true, get: funct
65
65
  /** Minimal YAML reader for the beats file — avoids a dependency for one shape. */
66
66
  function readBeatsFile(file) {
67
67
  // js-yaml ships with the app; require lazily so the CLI stays usable without it.
68
- // eslint-disable-next-line @typescript-eslint/no-var-requires
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
69
69
  const yaml = require('js-yaml');
70
70
  const parsed = yaml.load(fs.readFileSync(file, 'utf8'));
71
71
  if (!parsed || typeof parsed !== 'object') {
@@ -47,11 +47,22 @@ export function normalizeCeremonyLog(value: unknown): CeremonyLog | null {
47
47
  ...(typeof ceremony.research_context === "string"
48
48
  ? { research_context: ceremony.research_context }
49
49
  : {}),
50
+ ...(Array.isArray(ceremony.relations_honored)
51
+ ? { relations_honored: asStringArray(ceremony.relations_honored) }
52
+ : {}),
53
+ ...(typeof ceremony.episode_path === "string" ? { episode_path: ceremony.episode_path } : {}),
54
+ ...(typeof ceremony.episode_number === "number" ? { episode_number: ceremony.episode_number } : {}),
55
+ ...(typeof ceremony.source === "string" ? { source: ceremony.source } : {}),
56
+ ...(typeof ceremony.closes === "string" ? { closes: ceremony.closes } : {}),
57
+ ...(typeof ceremony.circle_id === "string" ? { circle_id: ceremony.circle_id } : {}),
50
58
  };
51
59
  }
52
60
 
53
61
  export function ceremonyEpisodePath(value: unknown): string | undefined {
54
62
  if (!value || typeof value !== "object") return undefined;
63
+ // Typed binding first (0.14.0), then the legacy JSON string in research_context.
64
+ const typed = (value as Record<string, unknown>).episode_path;
65
+ if (typeof typed === "string" && typed.length > 0) return typed;
55
66
  const researchContext = (value as Record<string, unknown>).research_context;
56
67
  if (typeof researchContext !== "string") return undefined;
57
68
 
@@ -71,6 +71,9 @@ interface StoredBeat {
71
71
  parent_beat_id?: string;
72
72
  sub_beats?: string[];
73
73
  origin?: { producer: string; source_ref?: string; method?: string };
74
+ /** Talking circle (0.14.0): who spoke, who witnessed. */
75
+ speaker?: string;
76
+ witnesses?: string[];
74
77
  }
75
78
 
76
79
  interface StoredCycle {
package/lib/store.ts CHANGED
@@ -175,6 +175,8 @@ export function createBeat(
175
175
  cycle_id: data.cycle_id,
176
176
  parent_beat_id: data.parent_beat_id,
177
177
  origin: data.origin ?? existing?.origin ?? { producer: 'rest' },
178
+ ...(data.speaker !== undefined ? { speaker: data.speaker } : {}),
179
+ ...(data.witnesses !== undefined ? { witnesses: data.witnesses } : {}),
178
180
  };
179
181
 
180
182
  // `strictAct` turns an act that contradicts the direction into an error
@@ -332,3 +334,34 @@ export function seedDemoData() {
332
334
 
333
335
  // Auto-seed on import
334
336
  seedDemoData();
337
+
338
+ // ── Talking-circle doors on beats (0.14.0) ──
339
+
340
+ /** One beat by id, normalized like the collection reads. */
341
+ export function getBeat(id: string): NarrativeBeat | undefined {
342
+ const raw = store.getBeat(id);
343
+ if (!raw) return undefined;
344
+ return extractBeats([raw])[0];
345
+ }
346
+
347
+ /**
348
+ * Add witnesses to a beat (a set: a name already present is not repeated) and,
349
+ * when given, name its speaker. Returns the stored beat, or undefined when
350
+ * there is no such beat. What was said is never rewritten here.
351
+ */
352
+ export function witnessBeat(
353
+ id: string,
354
+ patch: { witnesses?: string[]; speaker?: string },
355
+ ): NarrativeBeat | undefined {
356
+ const raw = store.getBeat(id) as (NarrativeBeat & Record<string, unknown>) | undefined;
357
+ if (!raw) return undefined;
358
+ const witnesses = new Set<string>(Array.isArray(raw.witnesses) ? raw.witnesses : []);
359
+ for (const w of patch.witnesses ?? []) witnesses.add(w);
360
+ const next = {
361
+ ...raw,
362
+ ...(patch.speaker !== undefined ? { speaker: patch.speaker } : {}),
363
+ ...(witnesses.size > 0 ? { witnesses: [...witnesses] } : {}),
364
+ };
365
+ store.createBeat(next as any);
366
+ return extractBeats([next])[0];
367
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.12.0",
3
+ "version": "0.14.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",
@@ -31,6 +31,7 @@
31
31
  "src/session-reader",
32
32
  "src/ceremony-protocol",
33
33
  "src/storage-provider",
34
+ "src/client",
34
35
  "src/data-store",
35
36
  "src/data-store-postgres",
36
37
  "src/graph-viz",
@@ -47,6 +48,7 @@
47
48
  "src/creative-problem-solving",
48
49
  "src/community-review",
49
50
  "src/consent-lifecycle",
51
+ "src/community-identity",
50
52
  "src/fire-keeper",
51
53
  "src/transformation-tracker",
52
54
  "src/ceremonial-diary",
@@ -62,7 +64,8 @@
62
64
  "dev": "next dev -p 8040",
63
65
  "build": "next build",
64
66
  "start": "next start -p 8040",
65
- "lint": "next lint",
67
+ "lint": "eslint .",
68
+ "check:deps": "node scripts/check-declared-deps.mjs",
66
69
  "test": "vitest run",
67
70
  "test:watch": "vitest",
68
71
  "version:patch": "node scripts/bump-versions.mjs patch && node scripts/sync-versions.mjs && shx rm -rf node_modules src/*/node_modules package-lock.json && npm install --legacy-peer-deps",
@@ -85,29 +88,29 @@
85
88
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
86
89
  },
87
90
  "dependencies": {
88
- "@medicine-wheel/ceremonial-diary": "^0.12.0",
89
- "@medicine-wheel/ceremony-protocol": "^0.12.0",
90
- "@medicine-wheel/community-review": "^0.12.0",
91
- "@medicine-wheel/consent-lifecycle": "^0.12.0",
92
- "@medicine-wheel/creative-orientation": "^0.12.0",
93
- "@medicine-wheel/data-store": "^0.12.0",
94
- "@medicine-wheel/data-store-postgres": "^0.12.0",
95
- "@medicine-wheel/fire-keeper": "^0.12.0",
96
- "@medicine-wheel/github-ceremony": "^0.12.0",
97
- "@medicine-wheel/graph-viz": "^0.12.0",
98
- "@medicine-wheel/importance-unit": "^0.12.0",
99
- "@medicine-wheel/mcp": "^4.12.0",
100
- "@medicine-wheel/narrative-cluster": "^0.12.0",
101
- "@medicine-wheel/narrative-engine": "^0.12.0",
102
- "@medicine-wheel/ontology-core": "^0.12.0",
103
- "@medicine-wheel/perception-layer": "^0.12.0",
104
- "@medicine-wheel/prompt-decomposition": "^0.12.0",
105
- "@medicine-wheel/relational-index": "^0.12.0",
106
- "@medicine-wheel/relational-query": "^0.12.0",
107
- "@medicine-wheel/session-reader": "^0.12.0",
108
- "@medicine-wheel/storage-provider": "^0.12.0",
109
- "@medicine-wheel/transformation-tracker": "^0.12.0",
110
- "@medicine-wheel/ui-components": "^0.12.0",
91
+ "@medicine-wheel/ceremonial-diary": "^0.14.0",
92
+ "@medicine-wheel/ceremony-protocol": "^0.14.0",
93
+ "@medicine-wheel/community-review": "^0.14.0",
94
+ "@medicine-wheel/consent-lifecycle": "^0.14.0",
95
+ "@medicine-wheel/creative-orientation": "^0.14.0",
96
+ "@medicine-wheel/data-store": "^0.14.0",
97
+ "@medicine-wheel/data-store-postgres": "^0.14.0",
98
+ "@medicine-wheel/fire-keeper": "^0.14.0",
99
+ "@medicine-wheel/github-ceremony": "^0.14.0",
100
+ "@medicine-wheel/graph-viz": "^0.14.0",
101
+ "@medicine-wheel/importance-unit": "^0.14.0",
102
+ "@medicine-wheel/mcp": "^4.14.0",
103
+ "@medicine-wheel/narrative-cluster": "^0.14.0",
104
+ "@medicine-wheel/narrative-engine": "^0.14.0",
105
+ "@medicine-wheel/ontology-core": "^0.14.0",
106
+ "@medicine-wheel/perception-layer": "^0.14.0",
107
+ "@medicine-wheel/prompt-decomposition": "^0.14.0",
108
+ "@medicine-wheel/relational-index": "^0.14.0",
109
+ "@medicine-wheel/relational-query": "^0.14.0",
110
+ "@medicine-wheel/session-reader": "^0.14.0",
111
+ "@medicine-wheel/storage-provider": "^0.14.0",
112
+ "@medicine-wheel/transformation-tracker": "^0.14.0",
113
+ "@medicine-wheel/ui-components": "^0.14.0",
111
114
  "@neondatabase/serverless": "^0.10.0",
112
115
  "@xyflow/react": "^12.3.0",
113
116
  "clsx": "^2.1.1",
@@ -128,6 +131,8 @@
128
131
  "@types/node": "^22.0.0",
129
132
  "@types/react": "^19.0.0",
130
133
  "@types/react-dom": "^19.0.0",
134
+ "eslint": "^9.39.5",
135
+ "eslint-config-next": "^15.5.25",
131
136
  "shx": "^0.4.0",
132
137
  "tailwindcss": "^4.1.0",
133
138
  "typescript": "^5.7.0",