@medicine-wheel/app 0.17.0 → 0.17.2

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
@@ -1,6 +1,6 @@
1
1
  # Medicine Wheel
2
2
 
3
- > An experimental TypeScript framework for relational healing, ceremonial inquiry, and Indigenous-aligned software development — grounded in the Four Directions, Wilson's three R's (Respect, Reciprocity, Responsibility), and OCAP® data sovereignty principles.
3
+ > An experimental TypeScript framework for relational healing, ceremonial inquiry, and Indigenous-aligned software development — grounded in the Four Directions, the respect, reciprocity and responsibility of Shawn Wilson's relational accountability (*Research Is Ceremony*, 2008, p. 99), and OCAP® data sovereignty principles.
4
4
 
5
5
  > [!WARNING]
6
6
  > **Experimental alpha.** APIs change between patch versions, packages appear and
@@ -100,7 +100,7 @@ Fire Keeper coordination agent — tends the ceremony fire, ensures relational i
100
100
  - **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/ceremony-protocol`
101
101
 
102
102
  ### [@medicine-wheel/community-review](src/community-review)
103
- Community-based ceremonial review protocol — implements Wilson's validation through Elder review circles, consensus-seeking, talking circle protocol, and relational accountability assessment.
103
+ Community-based ceremonial review protocol — talking circle protocol, review circles, consensus-seeking and relational accountability assessment, after the community-grounded credibility Wilson describes (*Research Is Ceremony*, 2008, pp. 121, 125, 131). Its Elder validation step is the package's own design.
104
104
 
105
105
  - **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/ceremony-protocol`, `zod`
106
106
 
@@ -120,7 +120,7 @@ Beat sequencing, cadence validation, arc completeness scoring, timeline building
120
120
  - **Dependencies:** `@medicine-wheel/ontology-core`
121
121
 
122
122
  ### [@medicine-wheel/importance-unit](src/importance-unit)
123
- ImportanceUnit — the relational unit of knowledge in Wilson's epistemology. Carries epistemic weight, source dimensions (Land/Dream/Code/Vision), circle depth tracking, and accountability links. Dream-state knowledge starts at 0.85+ weight; rational-filtered inputs start lower.
123
+ ImportanceUnit — this suite's relational unit of knowledge, after Wilson's relational epistemology. Carries epistemic weight, source dimensions (Land/Dream/Code/Vision), circle depth tracking, and accountability links. Dream-state knowledge starts at 0.85+ weight; rational-filtered inputs start lower.
124
124
 
125
125
  - **Dependencies:** `@medicine-wheel/ontology-core`, `zod`
126
126
 
@@ -0,0 +1,78 @@
1
+ import { NextResponse } from "next/server";
2
+ import { EpisodePathError } from "@miadi/episode-vessel";
3
+ import { episodeFolderOf, openEpisodeRoom, readEpisodeRoomText } from "@/lib/episode-files";
4
+
5
+ /**
6
+ * An episode's folder, read-only.
7
+ *
8
+ * GET /api/episodes/<chronicle:folder>/files the room's listing
9
+ * GET /api/episodes/<chronicle:folder>/files?path=<file> one text file, whole
10
+ *
11
+ * The rules — containment, the room, the audience — are `lib/episode-files.ts`
12
+ * and, beneath it, `@miadi/episode-vessel`. This file only says them in HTTP.
13
+ * Every answer carries a `state`, so a page can tell "this host has no
14
+ * chronicle" from "this episode is not on this host" from "this episode is for
15
+ * the community" — three empty lists that mean three different things.
16
+ *
17
+ * There is no PUT. The wheel reads an episode; Miadi writes it.
18
+ * Ref jgwill/medicine-wheel#153.
19
+ */
20
+
21
+ export const dynamic = "force-dynamic";
22
+ export const runtime = "nodejs";
23
+
24
+ const STATUS_OF_STATE: Record<string, number> = {
25
+ open: 200,
26
+ "no-root": 503,
27
+ absent: 404,
28
+ community: 403,
29
+ "outside-the-room": 403,
30
+ };
31
+
32
+ const STATUS_OF_CODE: Record<string, number> = {
33
+ ENOENT: 404,
34
+ TOO_LARGE: 413,
35
+ NO_ROOT: 503,
36
+ };
37
+
38
+ function answer(body: Record<string, unknown>, status: number) {
39
+ return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
40
+ }
41
+
42
+ function decoded(value: string): string {
43
+ try {
44
+ return decodeURIComponent(value);
45
+ } catch {
46
+ return value;
47
+ }
48
+ }
49
+
50
+ export async function GET(
51
+ request: Request,
52
+ { params }: { params: Promise<{ id: string }> },
53
+ ) {
54
+ const { id: rawId } = await params;
55
+ const id = decoded(rawId);
56
+ const folder = episodeFolderOf(id);
57
+ if (!folder) {
58
+ return answer(
59
+ { state: "not-an-episode", error: `Not an episode id: ${id}. Expected chronicle:<episode folder>.` },
60
+ 400,
61
+ );
62
+ }
63
+
64
+ const file = new URL(request.url).searchParams.get("path");
65
+ try {
66
+ const body = file === null ? openEpisodeRoom(folder) : readEpisodeRoomText(folder, file);
67
+ return answer(body, STATUS_OF_STATE[body.state] ?? 200);
68
+ } catch (error) {
69
+ if (error instanceof EpisodePathError) {
70
+ return answer(
71
+ { state: "refused", code: error.code, error: error.message },
72
+ STATUS_OF_CODE[error.code] ?? 400,
73
+ );
74
+ }
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ return answer({ state: "error", error: message }, 500);
77
+ }
78
+ }
@@ -1,13 +1,19 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * One episode: what it is, what it still holds open, and what it touches.
4
+ * One episode: what it is, what it holds, what it still holds open, and what
5
+ * it touches.
5
6
  *
6
- * The three panels answer the three things the graph could not. What this is,
7
- * from the node itself. What is open, from `?kind=attention&parent_id=<id>` —
8
- * a query the API has always accepted and no page ever sent. What it relates to,
9
- * from `/api/nodes/[id]/web`, which walks the traversal that shipped in
10
- * `relational-query` and went unimported.
7
+ * What this is, from the node itself, with the episodes before and after it in
8
+ * the chronicle. What it holds, from its folder — read-only, through the rules
9
+ * `@miadi/episode-vessel` states for every surface that opens one (see
10
+ * `components/episode-files-panel.tsx`). What was said and gathered for it —
11
+ * ceremonies, diary, inquiry weaves, plan perspectives, captures — from routes
12
+ * that filtered by episode long before any page asked them
13
+ * (`components/episode-holdings.tsx`). What is open, from
14
+ * `?kind=attention&parent_id=<id>`. What it relates to, from
15
+ * `/api/nodes/[id]/web`, which walks the traversal that shipped in
16
+ * `relational-query` and went unimported. Ref jgwill/medicine-wheel#153.
11
17
  *
12
18
  * The relations panel is where the wheel's current honesty shows. 101 of 106
13
19
  * containment links live only in `metadata.parent_id` and were never written as
@@ -21,6 +27,8 @@ import { useCallback, useEffect, useState } from "react";
21
27
  import { use } from "react";
22
28
  import Link from "next/link";
23
29
  import type { RelationalNode, RelationalEdge } from "@/lib/types";
30
+ import { EpisodeFilesPanel } from "@/components/episode-files-panel";
31
+ import { EpisodeHoldings } from "@/components/episode-holdings";
24
32
 
25
33
  // `description` lives on the store's node, not on ontology-core's — the same
26
34
  // widening `app/nodes/page.tsx` declares as `NodeRecord`. Named EpisodeNode
@@ -42,6 +50,46 @@ function metaString(node: EpisodeNode | null, key: string): string | null {
42
50
  return typeof value === "string" && value ? value : null;
43
51
  }
44
52
 
53
+ /**
54
+ * Where an episode sits in the chronicle: its folder's date, then its number —
55
+ * the order the folders keep on disk. Not `created_at`, which records when the
56
+ * wheel learned of an episode, and not the number alone, which the chronicle
57
+ * does not hand out in date order (episode 1000 follows episode 550 by two days).
58
+ */
59
+ function chronicleKey(node: EpisodeNode): string {
60
+ const match = (episodeFolderOf(node) ?? "").match(/^(\d{4}-\d{2}-\d{2})-episode-(\d+)/i);
61
+ return match ? `${match[1]}-${match[2].padStart(6, "0")}` : (node.created_at ?? "");
62
+ }
63
+
64
+ /**
65
+ * The episode's folder name — what the wheel's other records file it under.
66
+ *
67
+ * From the id when it is `chronicle:<folder>`. A few early episodes were
68
+ * registered under other ids (`node:knowledge:…`, a bare uuid); their node
69
+ * still carries the artefact reference, so the folder comes from
70
+ * `metadata.relative_path` when `metadata.root` names the chronicle.
71
+ */
72
+ function episodeFolderOf(node: EpisodeNode): string | null {
73
+ if (node.id.startsWith("chronicle:")) return node.id.slice("chronicle:".length) || null;
74
+ const relative = metaString(node, "relative_path");
75
+ if (metaString(node, "root") !== "MIADI_CHRONICLE_ROOT" || !relative) return null;
76
+ const folder = relative.split("/")[0];
77
+ return folder && folder !== relative ? folder : null;
78
+ }
79
+
80
+ function EpisodeLink({ node, label }: { node: EpisodeNode | null; label: string }) {
81
+ if (!node) return <span />;
82
+ return (
83
+ <Link
84
+ href={`/episodes/${encodeURIComponent(node.id)}`}
85
+ className="text-xs text-muted-foreground hover:underline min-w-0 truncate"
86
+ title={node.name}
87
+ >
88
+ {label} {node.name}
89
+ </Link>
90
+ );
91
+ }
92
+
45
93
  export default function EpisodePage({ params }: { params: Promise<{ id: string }> }) {
46
94
  const { id: rawId } = use(params);
47
95
  const id = decodeURIComponent(rawId);
@@ -49,6 +97,8 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
49
97
  const [episode, setEpisode] = useState<EpisodeNode | null>(null);
50
98
  const [attention, setAttention] = useState<EpisodeNode[]>([]);
51
99
  const [web, setWeb] = useState<{ nodes: EpisodeNode[]; edges: RelationalEdge[] } | null>(null);
100
+ const [before, setBefore] = useState<EpisodeNode | null>(null);
101
+ const [after, setAfter] = useState<EpisodeNode | null>(null);
52
102
  const [loading, setLoading] = useState(true);
53
103
  const [error, setError] = useState<string | null>(null);
54
104
 
@@ -67,6 +117,11 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
67
117
  const found = all.find((n) => n.id === id) ?? null;
68
118
  setEpisode(found);
69
119
 
120
+ const ordered = [...all].sort((a, b) => chronicleKey(a).localeCompare(chronicleKey(b)));
121
+ const at = ordered.findIndex((n) => n.id === id);
122
+ setBefore(at > 0 ? ordered[at - 1] : null);
123
+ setAfter(at >= 0 && at < ordered.length - 1 ? ordered[at + 1] : null);
124
+
70
125
  setAttention(attentionRes.ok ? ((await attentionRes.json()).nodes ?? []) : []);
71
126
 
72
127
  // A 404 here means the node is not in the wheel at all — distinct from a
@@ -85,6 +140,7 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
85
140
  }, [load]);
86
141
 
87
142
  const parentId = metaString(episode, "parent_id");
143
+ const folder = episode ? episodeFolderOf(episode) : null;
88
144
  const neighbours = (web?.nodes ?? []).filter((n) => n.id !== id);
89
145
  const parentHasEdge = Boolean(parentId && neighbours.some((n) => n.id === parentId));
90
146
 
@@ -117,7 +173,7 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
117
173
  </Link>
118
174
  <p className="mt-4 text-sm text-muted-foreground">
119
175
  No episode node with id <code className="font-mono">{id}</code>. The wheel answered and
120
- does not hold it — 102 of 185 episode folders on disk have no node yet.
176
+ does not hold it: an episode folder can exist before it is registered on the wheel.
121
177
  </p>
122
178
  </div>
123
179
  );
@@ -139,8 +195,16 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
139
195
  </div>
140
196
  <p className="font-mono text-xs text-muted-foreground mt-1 break-all">{episode.id}</p>
141
197
  {episode.description && <p className="text-sm mt-3">{episode.description}</p>}
198
+ {(before || after) && (
199
+ <nav className="flex justify-between gap-4 mt-3" aria-label="Neighbouring episodes">
200
+ <EpisodeLink node={before} label="←" />
201
+ <EpisodeLink node={after} label="→" />
202
+ </nav>
203
+ )}
142
204
  </div>
143
205
 
206
+ {folder && <EpisodeFilesPanel episodeId={`chronicle:${folder}`} />}
207
+
144
208
  <section>
145
209
  <h2 className="text-sm font-semibold mb-2">
146
210
  Open attention {attention.length > 0 && `(${attention.length})`}
@@ -163,6 +227,8 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
163
227
  )}
164
228
  </section>
165
229
 
230
+ {folder && <EpisodeHoldings episodeId={`chronicle:${folder}`} episodePath={folder} />}
231
+
166
232
  <section>
167
233
  <h2 className="text-sm font-semibold mb-2">
168
234
  Relations {neighbours.length > 0 && `(${neighbours.length})`}
@@ -172,8 +238,7 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
172
238
  <p className="mb-2 text-xs text-muted-foreground border rounded-lg p-3 bg-card">
173
239
  This episode records a parent in <code>metadata.parent_id</code> (
174
240
  <code className="break-all">{parentId}</code>) that was never written as a relation, so
175
- it does not appear below and the graph cannot draw it. 101 of 106 containment links in
176
- this wheel are in that state.
241
+ it does not appear below and the graph cannot draw it.
177
242
  </p>
178
243
  )}
179
244
 
@@ -0,0 +1,167 @@
1
+ "use client";
2
+
3
+ /**
4
+ * An episode's folder on the episode page — listed, and any text file read in
5
+ * place. Read-only: the wheel reads an episode, Miadi writes it.
6
+ *
7
+ * Text is shown as text. Markdown is not rendered here: rendering it means a
8
+ * renderer and a sanitizer between an episode's files and this page, and plain
9
+ * text is already readable. Media and documents are listed, not played.
10
+ *
11
+ * @see app/api/episodes/[id]/files/route.ts — and the rules it states
12
+ */
13
+
14
+ import { useCallback, useEffect, useRef, useState } from "react";
15
+
16
+ type EpisodeFile = {
17
+ relativePath: string;
18
+ name: string;
19
+ kind: "text" | "document" | "media";
20
+ size: number;
21
+ modifiedAt: string;
22
+ previewable: boolean;
23
+ guidance: boolean;
24
+ };
25
+
26
+ type Room =
27
+ | { state: "open"; episode_path: string; files: EpisodeFile[] }
28
+ | { state: "no-root" }
29
+ | { state: "absent"; episode_path: string }
30
+ | { state: "community"; episode_path: string }
31
+ | { state: "not-an-episode" | "refused" | "error"; error?: string };
32
+
33
+ type Opened = { path: string; content?: string; error?: string };
34
+
35
+ function formatSize(bytes: number): string {
36
+ if (bytes < 1024) return `${bytes} B`;
37
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
38
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
39
+ }
40
+
41
+ /** Why a folder is not shown, in words a reader can act on. */
42
+ function closedReason(room: Room): string | null {
43
+ switch (room.state) {
44
+ case "no-root":
45
+ return "This wheel's host has no chronicle folder (MIADI_CHRONICLE_ROOT), so it knows the episode exists but cannot open it.";
46
+ case "absent":
47
+ return "The episode's folder is not on this host. The wheel holds its record; the folder lives in a chronicle checkout that has not reached this machine.";
48
+ case "community":
49
+ return "This episode is for the community signed in. The wheel has no sign-in, so its folder stays closed here.";
50
+ case "open":
51
+ return null;
52
+ default:
53
+ return room.error ?? "The folder could not be opened.";
54
+ }
55
+ }
56
+
57
+ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
58
+ const base = `/api/episodes/${encodeURIComponent(episodeId)}/files`;
59
+ const [room, setRoom] = useState<Room | null>(null);
60
+ const [opened, setOpened] = useState<Opened | null>(null);
61
+ // The path last asked for, so a slow answer for an earlier click cannot
62
+ // replace the file the reader opened after it.
63
+ const wanted = useRef<string | null>(null);
64
+
65
+ useEffect(() => {
66
+ let live = true;
67
+ setRoom(null);
68
+ setOpened(null);
69
+ fetch(base)
70
+ .then((res) => res.json())
71
+ .then((body: Room) => live && setRoom(body))
72
+ .catch((e) => live && setRoom({ state: "error", error: e instanceof Error ? e.message : String(e) }));
73
+ return () => {
74
+ live = false;
75
+ };
76
+ }, [base]);
77
+
78
+ const toggle = useCallback(
79
+ async (file: EpisodeFile) => {
80
+ if (wanted.current === file.relativePath) {
81
+ wanted.current = null;
82
+ setOpened(null);
83
+ return;
84
+ }
85
+ wanted.current = file.relativePath;
86
+ setOpened({ path: file.relativePath });
87
+ let next: Opened;
88
+ try {
89
+ const res = await fetch(`${base}?path=${encodeURIComponent(file.relativePath)}`);
90
+ const body = await res.json();
91
+ next = res.ok
92
+ ? { path: file.relativePath, content: body.content }
93
+ : { path: file.relativePath, error: body.error ?? body.state ?? `HTTP ${res.status}` };
94
+ } catch (e) {
95
+ next = { path: file.relativePath, error: e instanceof Error ? e.message : String(e) };
96
+ }
97
+ if (wanted.current === file.relativePath) setOpened(next);
98
+ },
99
+ [base],
100
+ );
101
+
102
+ const files =
103
+ room?.state === "open"
104
+ ? // The episode's own work first; host and agent guidance after it.
105
+ [...room.files].sort((a, b) => Number(a.guidance) - Number(b.guidance))
106
+ : [];
107
+
108
+ return (
109
+ <section>
110
+ <h2 className="text-sm font-semibold mb-2">
111
+ Files {files.length > 0 && `(${files.length})`}
112
+ </h2>
113
+
114
+ {room === null ? (
115
+ <p className="text-sm text-muted-foreground">Opening the folder…</p>
116
+ ) : room.state !== "open" ? (
117
+ <p className="text-sm text-muted-foreground">{closedReason(room)}</p>
118
+ ) : files.length === 0 ? (
119
+ <p className="text-sm text-muted-foreground">The folder holds nothing the room shows.</p>
120
+ ) : (
121
+ <ul className="space-y-1">
122
+ {files.map((file) => {
123
+ const isOpen = opened?.path === file.relativePath;
124
+ return (
125
+ <li key={file.relativePath}>
126
+ <div className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap">
127
+ {file.previewable ? (
128
+ <button
129
+ onClick={() => toggle(file)}
130
+ className="text-sm font-mono text-left break-all hover:underline"
131
+ aria-expanded={isOpen}
132
+ >
133
+ {isOpen ? "▾" : "▸"} {file.relativePath}
134
+ </button>
135
+ ) : (
136
+ <span className="text-sm font-mono break-all text-muted-foreground">
137
+ {file.relativePath}
138
+ </span>
139
+ )}
140
+ <span className="text-xs text-muted-foreground shrink-0 flex gap-2 items-baseline">
141
+ {file.guidance && <span className="mw-badge">guidance</span>}
142
+ {!file.previewable && <span className="mw-badge">{file.kind}</span>}
143
+ <span>{formatSize(file.size)}</span>
144
+ <span>{file.modifiedAt.slice(0, 10)}</span>
145
+ </span>
146
+ </div>
147
+ {isOpen && (
148
+ <div className="mt-1 p-3 border rounded-lg bg-card">
149
+ {opened?.error ? (
150
+ <p className="text-sm text-muted-foreground">{opened.error}</p>
151
+ ) : opened?.content === undefined ? (
152
+ <p className="text-sm text-muted-foreground">Reading…</p>
153
+ ) : (
154
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words max-h-[70vh] overflow-auto">
155
+ {opened.content}
156
+ </pre>
157
+ )}
158
+ </div>
159
+ )}
160
+ </li>
161
+ );
162
+ })}
163
+ </ul>
164
+ )}
165
+ </section>
166
+ );
167
+ }
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ /**
4
+ * What the wheel holds for one episode, beyond its node.
5
+ *
6
+ * Every route read here has filtered by episode for some time — ceremonies,
7
+ * inquiry weaves, plan perspectives and captures by `episode_path`, diary
8
+ * entries by the episode's node id — and the episode page never asked any of
9
+ * them. Nothing here is new data; it is the wheel's own records, gathered where
10
+ * a person looking at the episode can see them. Ref jgwill/medicine-wheel#153.
11
+ */
12
+
13
+ import { useEffect, useState } from "react";
14
+
15
+ type Ceremony = {
16
+ id: string;
17
+ type: string;
18
+ direction?: string;
19
+ intentions?: string[];
20
+ timestamp: string;
21
+ };
22
+
23
+ type DiaryEntry = {
24
+ id: string;
25
+ timestamp: string;
26
+ phase?: string;
27
+ entryType?: string;
28
+ content: string;
29
+ metadata?: { participant_name?: string };
30
+ };
31
+
32
+ type InquiryWeave = {
33
+ id: string;
34
+ artefact?: { id?: string };
35
+ last_sync?: { state?: string };
36
+ issue?: string;
37
+ issue_url?: string;
38
+ };
39
+
40
+ type PlanPerspective = {
41
+ id: string;
42
+ plan?: { plan_filename?: string; captured_at?: string };
43
+ narrative?: { title?: string };
44
+ };
45
+
46
+ type Capture = {
47
+ id: string;
48
+ filename: string;
49
+ kind?: string;
50
+ artifact_role?: string;
51
+ device?: string;
52
+ registered_at?: string;
53
+ };
54
+
55
+ type Holdings = {
56
+ ceremonies: Ceremony[];
57
+ diary: DiaryEntry[];
58
+ weaves: InquiryWeave[];
59
+ perspectives: PlanPerspective[];
60
+ captures: Capture[];
61
+ };
62
+
63
+ const DIRECTION_ICONS: Record<string, string> = {
64
+ east: "🌅",
65
+ south: "🔥",
66
+ west: "🌊",
67
+ north: "❄️",
68
+ };
69
+
70
+ /** A list from one route, or [] when that route did not answer — one silent route must not blank the rest. */
71
+ async function listFrom<T>(url: string, key: string): Promise<{ items: T[]; failed: boolean }> {
72
+ try {
73
+ const res = await fetch(url);
74
+ if (!res.ok) return { items: [], failed: true };
75
+ const body = await res.json();
76
+ return { items: Array.isArray(body?.[key]) ? body[key] : [], failed: false };
77
+ } catch {
78
+ return { items: [], failed: true };
79
+ }
80
+ }
81
+
82
+ function byTime<T extends { timestamp: string }>(a: T, b: T): number {
83
+ return a.timestamp.localeCompare(b.timestamp);
84
+ }
85
+
86
+ export function EpisodeHoldings({ episodeId, episodePath }: { episodeId: string; episodePath: string }) {
87
+ const [holdings, setHoldings] = useState<Holdings | null>(null);
88
+ const [silent, setSilent] = useState<string[]>([]);
89
+
90
+ useEffect(() => {
91
+ let live = true;
92
+ const p = encodeURIComponent(episodePath);
93
+ Promise.all([
94
+ listFrom<Ceremony>(`/api/ceremonies?episode_path=${p}&limit=all`, "ceremonies"),
95
+ listFrom<DiaryEntry>(`/api/diary?chronicle=${encodeURIComponent(episodeId)}&limit=all`, "entries"),
96
+ listFrom<InquiryWeave>(`/api/inquiry-weaves?episode_path=${p}`, "inquiry_weaves"),
97
+ listFrom<PlanPerspective>(`/api/plan-perspectives?episode_path=${p}`, "plan_perspectives"),
98
+ listFrom<Capture>(`/api/captures?episode_path=${p}`, "captures"),
99
+ ]).then(([ceremonies, diary, weaves, perspectives, captures]) => {
100
+ if (!live) return;
101
+ setHoldings({
102
+ ceremonies: [...ceremonies.items].sort(byTime),
103
+ diary: [...diary.items].sort(byTime),
104
+ weaves: weaves.items,
105
+ perspectives: perspectives.items,
106
+ captures: captures.items,
107
+ });
108
+ setSilent(
109
+ (
110
+ [
111
+ ["ceremonies", ceremonies],
112
+ ["diary entries", diary],
113
+ ["inquiry weaves", weaves],
114
+ ["plan perspectives", perspectives],
115
+ ["captures", captures],
116
+ ] as const
117
+ )
118
+ .filter(([, r]) => r.failed)
119
+ .map(([name]) => name),
120
+ );
121
+ });
122
+ return () => {
123
+ live = false;
124
+ };
125
+ }, [episodeId, episodePath]);
126
+
127
+ if (!holdings) {
128
+ return (
129
+ <section>
130
+ <h2 className="text-sm font-semibold mb-2">Held on the wheel</h2>
131
+ <p className="text-sm text-muted-foreground">Asking the wheel…</p>
132
+ </section>
133
+ );
134
+ }
135
+
136
+ const { ceremonies, diary, weaves, perspectives, captures } = holdings;
137
+ // A route that did not answer is named as silent, not as empty.
138
+ const empty = (
139
+ [
140
+ ["ceremonies", ceremonies],
141
+ ["diary entries", diary],
142
+ ["inquiry weaves", weaves],
143
+ ["plan perspectives", perspectives],
144
+ ["captures", captures],
145
+ ] as const
146
+ )
147
+ .filter(([name, items]) => items.length === 0 && !silent.includes(name))
148
+ .map(([name]) => name);
149
+
150
+ return (
151
+ <section className="space-y-4">
152
+ <h2 className="text-sm font-semibold">Held on the wheel</h2>
153
+
154
+ {ceremonies.length > 0 && (
155
+ <div>
156
+ <h3 className="text-xs font-semibold text-muted-foreground mb-1">Ceremonies ({ceremonies.length})</h3>
157
+ <ul className="space-y-1">
158
+ {ceremonies.map((c) => (
159
+ <li key={c.id} className="p-2 border rounded-lg bg-card">
160
+ <div className="flex items-baseline justify-between gap-2 flex-wrap">
161
+ <span className="text-sm font-medium">
162
+ {c.direction && DIRECTION_ICONS[c.direction]} {c.type.replace(/_/g, " ")}
163
+ </span>
164
+ <span className="text-xs text-muted-foreground">{c.timestamp.slice(0, 16).replace("T", " ")}</span>
165
+ </div>
166
+ {c.intentions?.[0] && <p className="text-sm text-muted-foreground mt-1">{c.intentions[0]}</p>}
167
+ </li>
168
+ ))}
169
+ </ul>
170
+ </div>
171
+ )}
172
+
173
+ {diary.length > 0 && (
174
+ <div>
175
+ <h3 className="text-xs font-semibold text-muted-foreground mb-1">Diary ({diary.length})</h3>
176
+ <ul className="space-y-1">
177
+ {diary.map((d) => (
178
+ <li key={d.id} className="p-2 border rounded-lg bg-card">
179
+ <p className="text-xs text-muted-foreground">
180
+ {[d.metadata?.participant_name, d.entryType, d.phase].filter(Boolean).join(" · ")}
181
+ </p>
182
+ <p className="text-sm mt-1 whitespace-pre-wrap">{d.content}</p>
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ </div>
187
+ )}
188
+
189
+ {weaves.length > 0 && (
190
+ <div>
191
+ <h3 className="text-xs font-semibold text-muted-foreground mb-1">Inquiry weaves ({weaves.length})</h3>
192
+ <ul className="space-y-1">
193
+ {weaves.map((w) => (
194
+ <li key={w.id} className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-2 flex-wrap">
195
+ <span className="text-sm font-mono break-all">{w.artefact?.id ?? w.id}</span>
196
+ <span className="text-xs text-muted-foreground flex gap-2">
197
+ {w.last_sync?.state && <span className="mw-badge">{w.last_sync.state}</span>}
198
+ {w.issue_url?.startsWith("https://") ? (
199
+ <a href={w.issue_url} className="hover:underline" target="_blank" rel="noreferrer">
200
+ {w.issue ?? "issue"}
201
+ </a>
202
+ ) : (
203
+ w.issue
204
+ )}
205
+ </span>
206
+ </li>
207
+ ))}
208
+ </ul>
209
+ </div>
210
+ )}
211
+
212
+ {perspectives.length > 0 && (
213
+ <div>
214
+ <h3 className="text-xs font-semibold text-muted-foreground mb-1">Plan perspectives ({perspectives.length})</h3>
215
+ <ul className="space-y-1">
216
+ {perspectives.map((pp) => (
217
+ <li key={pp.id} className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-2 flex-wrap">
218
+ <span className="text-sm">{pp.narrative?.title ?? pp.plan?.plan_filename ?? pp.id}</span>
219
+ <span className="text-xs text-muted-foreground">{pp.plan?.captured_at?.slice(0, 10)}</span>
220
+ </li>
221
+ ))}
222
+ </ul>
223
+ </div>
224
+ )}
225
+
226
+ {captures.length > 0 && (
227
+ <div>
228
+ <h3 className="text-xs font-semibold text-muted-foreground mb-1">Captures ({captures.length})</h3>
229
+ <ul className="space-y-1">
230
+ {captures.map((c) => (
231
+ <li key={c.id} className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-2 flex-wrap">
232
+ <span className="text-sm font-mono break-all">{c.filename}</span>
233
+ <span className="text-xs text-muted-foreground flex gap-2">
234
+ <span className="mw-badge">{c.artifact_role ?? c.kind ?? "capture"}</span>
235
+ {c.device && <span>{c.device}</span>}
236
+ <span>{c.registered_at?.slice(0, 10)}</span>
237
+ </span>
238
+ </li>
239
+ ))}
240
+ </ul>
241
+ </div>
242
+ )}
243
+
244
+ {empty.length > 0 && (
245
+ <p className="text-xs text-muted-foreground">No {empty.join(", ")} bound to this episode.</p>
246
+ )}
247
+ {silent.length > 0 && (
248
+ <p className="text-xs text-muted-foreground">The wheel did not answer for: {silent.join(", ")}.</p>
249
+ )}
250
+ </section>
251
+ );
252
+ }
@@ -583,7 +583,7 @@ mw_get_direction { node_id } # service → West, tenant → Sout
583
583
  \`detectPortConflicts\`, \`preconditionGuard\`, \`readyService\`, \`reconcile\`
584
584
  - \`@medicine-wheel/ontology-core\` — \`part-of\`, \`ordered-after\`, \`binds-port\` in
585
585
  the governed \`KINSHIP_EDGE_TYPES\`; \`INFRA_ENTITY_BINDING\` for which closed
586
- \`NodeType\` each kind rides. The union stays closed at six
586
+ \`NodeType\` each kind rides. The union stays closed at eight
587
587
  - Persistence: JSONL, or the server API when \`MW_API_URL\` is set
588
588
 
589
589
  ## What this skill does NOT do
@@ -0,0 +1,170 @@
1
+ /**
2
+ * An episode's folder, opened from the wheel — read-only.
3
+ *
4
+ * Every `chronicle_episode` node is registered under `chronicle:<folder>` and
5
+ * carries an artefact reference: `metadata.root` names `MIADI_CHRONICLE_ROOT`
6
+ * and `metadata.relative_path` the episode's `episode.yaml` beneath it. The
7
+ * wheel has always recorded where the work is and never opened it, so the
8
+ * Episodes tab could show an episode's name and nothing it contains.
9
+ *
10
+ * The rules for what an episode folder holds and what may be read from it are
11
+ * not written here. They live in `@miadi/episode-vessel`, published from
12
+ * jgwill/Miadi and used by every other surface that opens a vessel — the Miadi
13
+ * room and its file routes, the terminal browser, the phone. A second copy of
14
+ * them here is how two surfaces come to disagree about what is private.
15
+ *
16
+ * Two of those rules decide what the wheel shows. Both are applied as for a
17
+ * visitor, because the wheel has no signed-in reader:
18
+ *
19
+ * - **the room rule**: `ceremonies/` (a circle's notes and participants) and
20
+ * `captures/` (recordings) are not part of the room. The listing leaves them
21
+ * out and a read refuses them.
22
+ * - **the audience rule**: an episode whose `episode.yaml` says
23
+ * `audience: community` is closed to a visitor — no listing, no read.
24
+ *
25
+ * So the wheel shows no more of a folder than Miadi's public room already does.
26
+ * Nothing here writes. Ref jgwill/medicine-wheel#153.
27
+ */
28
+
29
+ import path from "node:path";
30
+ import {
31
+ EpisodePathError,
32
+ isOutsideTheRoom,
33
+ listEpisodeFiles,
34
+ parseEpisodeFolder,
35
+ readEpisodeAudience,
36
+ readEpisodeText,
37
+ resolveEpisodeDir,
38
+ resolveEpisodeFile,
39
+ type EpisodeFile,
40
+ type FileRevision,
41
+ } from "@miadi/episode-vessel";
42
+
43
+ export type { EpisodeFile };
44
+
45
+ export interface EpisodeRootOptions {
46
+ /** The chronicle root. Defaults to `MIADI_CHRONICLE_ROOT`. */
47
+ root?: string;
48
+ }
49
+
50
+ /** Why a folder could not be opened. Each one is a different thing to tell a reader. */
51
+ export type EpisodeRoomClosed =
52
+ /** This host was given no chronicle root, or the one it names does not exist. */
53
+ | { state: "no-root" }
54
+ /** The chronicle on this host has no folder by that name. */
55
+ | { state: "absent"; episode_path: string }
56
+ /** The episode is for the community signed in, and the wheel has no signed-in reader. */
57
+ | { state: "community"; episode_path: string };
58
+
59
+ export type EpisodeRoom =
60
+ | { state: "open"; episode_path: string; files: EpisodeFile[] }
61
+ | EpisodeRoomClosed;
62
+
63
+ export type EpisodeRoomText =
64
+ | {
65
+ state: "open";
66
+ episode_path: string;
67
+ path: string;
68
+ content: string;
69
+ contentType: string;
70
+ size: number;
71
+ revision: FileRevision;
72
+ }
73
+ /** Under `ceremonies/` or `captures/`: not part of the room. */
74
+ | { state: "outside-the-room"; episode_path: string; path: string }
75
+ | EpisodeRoomClosed;
76
+
77
+ const ID_PREFIX = "chronicle:";
78
+
79
+ /**
80
+ * `chronicle:<folder>` → `<folder>`, or null when the id does not name exactly
81
+ * one episode folder.
82
+ *
83
+ * `parseEpisodeFolder` accepts any slug, `/.` included, and `<folder>/.`
84
+ * resolves to the same directory on disk while reading as a different name to
85
+ * the manifest reader — the hole that opened a community room in Miadi
86
+ * (jgwill/Miadi#712). A bare name is this module's own test.
87
+ */
88
+ export function episodeFolderOf(id: string): string | null {
89
+ const value = id.trim();
90
+ if (!value.startsWith(ID_PREFIX)) return null;
91
+ const folder = value.slice(ID_PREFIX.length);
92
+ if (!folder || /[\\/]/.test(folder) || folder.startsWith(".")) return null;
93
+ return parseEpisodeFolder(folder) ? folder : null;
94
+ }
95
+
96
+ function rootOf(options: EpisodeRootOptions): string | null {
97
+ const root = options.root ?? process.env.MIADI_CHRONICLE_ROOT;
98
+ return root?.trim() ? root.trim() : null;
99
+ }
100
+
101
+ /** The gate both a listing and a read pass through, in the same order. */
102
+ function gate(folder: string, root: string | null): EpisodeRoomClosed | null {
103
+ if (!root) return { state: "no-root" };
104
+ try {
105
+ resolveEpisodeDir(folder, { root });
106
+ } catch (error) {
107
+ if (error instanceof EpisodePathError && error.code === "NO_ROOT") return { state: "no-root" };
108
+ if (error instanceof EpisodePathError && error.code === "ENOENT") {
109
+ return { state: "absent", episode_path: folder };
110
+ }
111
+ throw error;
112
+ }
113
+ if (readEpisodeAudience(folder, { root }) === "community") {
114
+ return { state: "community", episode_path: folder };
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /** The files of one episode's room: everything the vessel lists except what is outside the room. */
120
+ export function openEpisodeRoom(folder: string, options: EpisodeRootOptions = {}): EpisodeRoom {
121
+ const root = rootOf(options);
122
+ const closed = gate(folder, root);
123
+ if (closed) return closed;
124
+ const files = listEpisodeFiles(folder, {
125
+ root: root!,
126
+ // Applied before a file consumes the listing cap, so a hundred capture
127
+ // files cannot push an ordinary artefact off the end.
128
+ include: (relativePath) => !isOutsideTheRoom(relativePath),
129
+ });
130
+ return { state: "open", episode_path: folder, files };
131
+ }
132
+
133
+ /**
134
+ * One text file of the room, whole.
135
+ *
136
+ * Throws `EpisodePathError` for what the vessel refuses — an unsafe path
137
+ * (`INVALID_PATH`), a missing file (`ENOENT`), text too large to read whole
138
+ * (`TOO_LARGE`), or a file that is not text. The HTTP edge maps those once.
139
+ */
140
+ export function readEpisodeRoomText(
141
+ folder: string,
142
+ relativePath: string,
143
+ options: EpisodeRootOptions = {},
144
+ ): EpisodeRoomText {
145
+ const root = rootOf(options);
146
+ const closed = gate(folder, root);
147
+ if (closed) return closed;
148
+
149
+ // Validates the path (no `.`, `..`, empty or dot-prefixed segment) and
150
+ // refuses a symlinked file, but a linked *directory* partway down still
151
+ // resolves. The room rule is asked of both the name the caller gave and the
152
+ // file it really is, so `notes/<id>/notes.md` through a link to
153
+ // `ceremonies/` cannot read a ceremony's notes under another name.
154
+ const resolved = resolveEpisodeFile(folder, relativePath, { root: root! });
155
+ const real = path.relative(resolved.dir, resolved.path).split(path.sep).join("/");
156
+ if (isOutsideTheRoom(relativePath) || isOutsideTheRoom(real)) {
157
+ return { state: "outside-the-room", episode_path: folder, path: relativePath };
158
+ }
159
+
160
+ const text = readEpisodeText(folder, relativePath, { root: root! });
161
+ return {
162
+ state: "open",
163
+ episode_path: folder,
164
+ path: relativePath,
165
+ content: text.content,
166
+ contentType: text.contentType,
167
+ size: text.size,
168
+ revision: text.revision,
169
+ };
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
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",
@@ -91,32 +91,33 @@
91
91
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
92
92
  },
93
93
  "dependencies": {
94
- "@medicine-wheel/ceremonial-diary": "^0.17.0",
95
- "@medicine-wheel/ceremony-protocol": "^0.17.0",
96
- "@medicine-wheel/community-identity": "^0.17.0",
97
- "@medicine-wheel/community-review": "^0.17.0",
98
- "@medicine-wheel/consent-lifecycle": "^0.17.0",
99
- "@medicine-wheel/creative-orientation": "^0.17.0",
100
- "@medicine-wheel/data-store": "^0.17.0",
101
- "@medicine-wheel/data-store-postgres": "^0.17.0",
102
- "@medicine-wheel/fire-keeper": "^0.17.0",
103
- "@medicine-wheel/github-ceremony": "^0.17.0",
104
- "@medicine-wheel/graph-viz": "^0.17.0",
105
- "@medicine-wheel/honcho": "^0.17.0",
106
- "@medicine-wheel/importance-unit": "^0.17.0",
107
- "@medicine-wheel/mcp": "^4.17.0",
108
- "@medicine-wheel/memory": "^0.17.0",
109
- "@medicine-wheel/narrative-cluster": "^0.17.0",
110
- "@medicine-wheel/narrative-engine": "^0.17.0",
111
- "@medicine-wheel/ontology-core": "^0.17.0",
112
- "@medicine-wheel/perception-layer": "^0.17.0",
113
- "@medicine-wheel/prompt-decomposition": "^0.17.0",
114
- "@medicine-wheel/relational-index": "^0.17.0",
115
- "@medicine-wheel/relational-query": "^0.17.0",
116
- "@medicine-wheel/session-reader": "^0.17.0",
117
- "@medicine-wheel/storage-provider": "^0.17.0",
118
- "@medicine-wheel/transformation-tracker": "^0.17.0",
119
- "@medicine-wheel/ui-components": "^0.17.0",
94
+ "@medicine-wheel/ceremonial-diary": "^0.17.2",
95
+ "@medicine-wheel/ceremony-protocol": "^0.17.2",
96
+ "@medicine-wheel/community-identity": "^0.17.2",
97
+ "@medicine-wheel/community-review": "^0.17.2",
98
+ "@medicine-wheel/consent-lifecycle": "^0.17.2",
99
+ "@medicine-wheel/creative-orientation": "^0.17.2",
100
+ "@medicine-wheel/data-store": "^0.17.2",
101
+ "@medicine-wheel/data-store-postgres": "^0.17.2",
102
+ "@medicine-wheel/fire-keeper": "^0.17.2",
103
+ "@medicine-wheel/github-ceremony": "^0.17.2",
104
+ "@medicine-wheel/graph-viz": "^0.17.2",
105
+ "@medicine-wheel/honcho": "^0.17.2",
106
+ "@medicine-wheel/importance-unit": "^0.17.2",
107
+ "@medicine-wheel/mcp": "^4.17.2",
108
+ "@medicine-wheel/memory": "^0.17.2",
109
+ "@medicine-wheel/narrative-cluster": "^0.17.2",
110
+ "@medicine-wheel/narrative-engine": "^0.17.2",
111
+ "@medicine-wheel/ontology-core": "^0.17.2",
112
+ "@medicine-wheel/perception-layer": "^0.17.2",
113
+ "@medicine-wheel/prompt-decomposition": "^0.17.2",
114
+ "@medicine-wheel/relational-index": "^0.17.2",
115
+ "@medicine-wheel/relational-query": "^0.17.2",
116
+ "@medicine-wheel/session-reader": "^0.17.2",
117
+ "@medicine-wheel/storage-provider": "^0.17.2",
118
+ "@medicine-wheel/transformation-tracker": "^0.17.2",
119
+ "@medicine-wheel/ui-components": "^0.17.2",
120
+ "@miadi/episode-vessel": "^0.2.1",
120
121
  "@neondatabase/serverless": "^0.10.0",
121
122
  "@xyflow/react": "^12.3.0",
122
123
  "clsx": "^2.1.1",