@medicine-wheel/app 0.5.1 → 0.5.3

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/ALPHA.md ADDED
@@ -0,0 +1,59 @@
1
+ # This suite is experimental alpha
2
+
3
+ **Read this before depending on anything here.**
4
+
5
+ The Medicine Wheel Developer Suite is under active development and is published
6
+ in the open so the work can be examined, discussed, and improved. It is not
7
+ finished software. Publishing early is deliberate — but so is telling you what
8
+ you are installing.
9
+
10
+ ## What that means concretely
11
+
12
+ - **APIs change between patch versions.** All packages move in lockstep, so a
13
+ bump you would normally read as "nothing changed for me" may still change
14
+ what you depend on. Pin exact versions.
15
+ - **Packages appear, and their boundaries move.** Three packages were added in
16
+ `0.5.3`. Where a responsibility lives is still being decided.
17
+ - **Some packages are intentionally small first cuts.** They express an
18
+ intention with a working minimum rather than a finished surface. Their
19
+ READMEs say so where it applies.
20
+ - **Storage shapes are still settling.** Records written by one version may
21
+ need repair by a later one. Do not treat any store here as an archive of
22
+ record yet.
23
+ - **Test coverage is real but uneven.** 210 tests pass at `0.5.3`; that is a
24
+ floor, not a claim of completeness.
25
+
26
+ ## What it does *not* mean
27
+
28
+ It does not mean the work is careless. Defects found are fixed and written
29
+ down, specifications are versioned alongside the code in `rispecs/`, and
30
+ sessions are chronicled. The alpha label is about **stability of contract**,
31
+ not about standard of care.
32
+
33
+ ## Cultural material
34
+
35
+ Parts of this suite encode Indigenous relational research methodology —
36
+ Four Directions structure, ceremony protocol, OCAP® compliance surfaces,
37
+ relational accountability. That material is included because the software is
38
+ *for* that way of working, not as decoration.
39
+
40
+ If you are building on those parts, please read them as an invitation to work
41
+ relationally rather than as a finished implementation of anyone's protocol.
42
+ Where a design question is properly a knowledge holder's decision rather than
43
+ an engineer's, the specs try to say so. Where we have got that wrong, telling
44
+ us is a contribution.
45
+
46
+ ## If you are evaluating this
47
+
48
+ The most useful things to read first:
49
+
50
+ - `rispecs/` — the specifications, versioned with the code
51
+ - `README.md` — what the suite is
52
+ - Open issues — where the known edges are, stated plainly
53
+
54
+ Feedback is genuinely wanted at this stage, including "this boundary is wrong"
55
+ and "this should not be software".
56
+
57
+ ## License
58
+
59
+ MIT — see `LICENSE`.
package/Dockerfile CHANGED
@@ -6,7 +6,7 @@
6
6
  # mwsrv --docker -D /path/to/project
7
7
  #
8
8
  # Manual usage:
9
- # docker run --rm -p 3940:3940 \
9
+ # docker run --rm -p 8040:8040 \
10
10
  # -v /path/to/project/.mw/store:/data/store \
11
11
  # -e MW_DATA_DIR=/data/store \
12
12
  # jgwill/medicine-wheel:app
@@ -41,7 +41,7 @@ FROM node:22-alpine AS runner
41
41
  WORKDIR /app
42
42
 
43
43
  ENV NODE_ENV=production
44
- ENV PORT=3940
44
+ ENV PORT=8040
45
45
  ENV MW_STORAGE_PROVIDER=jsonl
46
46
  ENV MW_DATA_DIR=/data/store
47
47
 
@@ -63,6 +63,6 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
63
63
  # Volume for persistent data
64
64
  VOLUME ["/data/store"]
65
65
 
66
- EXPOSE 3940
66
+ EXPOSE 8040
67
67
 
68
68
  ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  > A first 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.
4
4
 
5
+ > [!WARNING]
6
+ > **Experimental alpha.** APIs change between patch versions, packages appear and
7
+ > their boundaries move, and storage shapes are still settling. Published in the
8
+ > open so the work can be examined and improved — pin exact versions, and read
9
+ > [ALPHA.md](./ALPHA.md) before depending on any of it.
10
+
5
11
  ## NEWS
6
12
 
7
13
  * Full postgres (neon) data provider !
@@ -12,15 +12,24 @@ export async function GET() {
12
12
  export async function POST(request: Request) {
13
13
  try {
14
14
  const body = await request.json();
15
+ // Pass the caller's id and timestamp through when supplied. A client that
16
+ // authored a beat holds its id and will look it up again — minting a fresh
17
+ // one here hands back a beat the caller can never find.
15
18
  const beat = createBeat({
19
+ id: body.id,
20
+ timestamp: body.timestamp,
16
21
  direction: body.direction,
17
22
  title: body.title,
18
23
  description: body.description,
19
24
  prose: body.prose,
20
25
  ceremonies: body.ceremonies ?? [],
21
26
  learnings: body.learnings ?? [],
22
- act: body.act ?? 1,
27
+ act: body.act,
23
28
  relations_honored: body.relations_honored ?? [],
29
+ cycle_id: body.cycle_id,
30
+ parent_beat_id: body.parent_beat_id,
31
+ sub_beats: body.sub_beats,
32
+ origin: body.origin,
24
33
  });
25
34
  return NextResponse.json(beat, { status: 201 });
26
35
  } catch (error: any) {
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { getAllCycles, createCycle } from "@/lib/store";
2
+ import { getAllCycles, createCycle, upsertCycle } from "@/lib/store";
3
3
 
4
4
  export async function GET() {
5
5
  try {
@@ -12,6 +12,17 @@ export async function GET() {
12
12
  export async function POST(request: Request) {
13
13
  try {
14
14
  const body = await request.json();
15
+
16
+ // A body carrying an id names the cycle the caller already holds — amend
17
+ // it if we know it, open it under that id if we do not. Minting a fresh
18
+ // id here is how archiving and beat-binding duplicated cycles instead of
19
+ // amending them; refusing outright is how a client's own id vanished.
20
+ if (typeof body.id === "string" && body.id.length > 0) {
21
+ const existed = getAllCycles().some((c) => c.id === body.id);
22
+ const cycle = upsertCycle(body);
23
+ return NextResponse.json(cycle, { status: existed ? 200 : 201 });
24
+ }
25
+
15
26
  const cycle = createCycle({
16
27
  research_question: body.research_question,
17
28
  current_direction: body.current_direction,
package/dist/cli/mw.js CHANGED
@@ -47,7 +47,7 @@ const path = __importStar(require("path"));
47
47
  const fs = __importStar(require("fs"));
48
48
  const skills_1 = require("./skills");
49
49
  // ── Config ────────────────────────────────────────────────────────
50
- const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:3940';
50
+ const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:8040';
51
51
  const MW_FORMAT = process.env.MW_FORMAT ?? 'pretty';
52
52
  function resolvePackageRoot() {
53
53
  try {
@@ -268,7 +268,7 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
268
268
  mw help This help
269
269
 
270
270
  ENVIRONMENT
271
- MW_API_URL API base URL (default: http://localhost:3940)
271
+ MW_API_URL API base URL (default: http://localhost:8040)
272
272
  MW_MCP_PATH Optional MCP server path (auto-detected when locally available)
273
273
  MW_FORMAT Output format: pretty (default), json, quiet
274
274
  `);
package/dist/cli/mwsrv.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * mwsrv --docker -D /path/to/project
11
11
  *
12
12
  * Options:
13
- * --port, -p <port> Port to listen on (default: 3940)
13
+ * --port, -p <port> Port to listen on (default: 8040)
14
14
  * --directory, -D <dir> Host directory containing (or for) .mw/store (default: cwd)
15
15
  * --docker Run in Docker container
16
16
  * --pull Pull Docker image before starting
@@ -63,8 +63,8 @@ const C = {
63
63
  south: '\x1b[31m',
64
64
  reset: '\x1b[0m',
65
65
  };
66
- const DEFAULT_PORT = 3940;
67
- const CONTAINER_PORT = 3940;
66
+ const DEFAULT_PORT = 8040;
67
+ const CONTAINER_PORT = 8040;
68
68
  function parseArgs(argv) {
69
69
  const args = argv.slice(2);
70
70
  const flags = {};
@@ -12,4 +12,4 @@ docker push "${IMAGE}"
12
12
 
13
13
  echo "✅ Done: ${IMAGE}"
14
14
  echo "Run with:"
15
- echo " docker run --rm -p 3940:3940 -v /abs/path/to/project/.mw/store:/data/store ${IMAGE}"
15
+ echo " docker run --rm -p 8040:8040 -v /abs/path/to/project/.mw/store:/data/store ${IMAGE}"
@@ -7,10 +7,10 @@ set -e
7
7
  # Environment variables:
8
8
  # MW_DATA_DIR — path to JSONL store directory (default: /data/store)
9
9
  # MW_STORAGE_PROVIDER — storage backend (default: jsonl)
10
- # PORT — HTTP port (default: 3940)
10
+ # PORT — HTTP port (default: 8040)
11
11
 
12
12
  STORE_DIR="${MW_DATA_DIR:-/data/store}"
13
- PORT="${PORT:-3940}"
13
+ PORT="${PORT:-8040}"
14
14
 
15
15
  mkdir -p "$STORE_DIR"
16
16
 
@@ -0,0 +1,58 @@
1
+ import type { DirectionName, NarrativeBeat } from "./types";
2
+
3
+ interface BeatListResponse {
4
+ beats?: unknown;
5
+ }
6
+
7
+ const DIRECTION_NAMES = new Set<DirectionName>(["east", "south", "west", "north"]);
8
+
9
+ function asStringArray(value: unknown): string[] {
10
+ return Array.isArray(value)
11
+ ? value.filter((entry): entry is string => typeof entry === "string")
12
+ : [];
13
+ }
14
+
15
+ /**
16
+ * Repair one stored beat into a shape the arc readers can render.
17
+ *
18
+ * Unlike the cycle normalizer this keeps every field it was handed instead of
19
+ * rebuilding from a whitelist: beats carry additive provenance (origin,
20
+ * sub_beats, parent_beat_id, act) and a whitelist would silently drop whatever
21
+ * the narrative engine adds after this file was written. Only the fields a
22
+ * reader indexes into are coerced — a beat recorded before `learnings` and
23
+ * `relations_honored` existed takes down a page that reads `.length` on them,
24
+ * and one with no `direction` is looked up as a colour key that does not exist.
25
+ */
26
+ export function normalizeNarrativeBeat(value: unknown): NarrativeBeat | null {
27
+ if (!value || typeof value !== "object") return null;
28
+
29
+ const beat = value as Record<string, unknown>;
30
+ if (typeof beat.id !== "string") return null;
31
+
32
+ return {
33
+ ...(beat as unknown as NarrativeBeat),
34
+ id: beat.id,
35
+ direction:
36
+ typeof beat.direction === "string" &&
37
+ DIRECTION_NAMES.has(beat.direction as DirectionName)
38
+ ? (beat.direction as DirectionName)
39
+ : "east",
40
+ ceremonies: asStringArray(beat.ceremonies),
41
+ learnings: asStringArray(beat.learnings),
42
+ relations_honored: asStringArray(beat.relations_honored),
43
+ timestamp:
44
+ typeof beat.timestamp === "string" ? beat.timestamp : new Date(0).toISOString(),
45
+ };
46
+ }
47
+
48
+ export function extractBeats(response: unknown): NarrativeBeat[] {
49
+ const candidate = Array.isArray(response)
50
+ ? response
51
+ : response && typeof response === "object" && Array.isArray((response as BeatListResponse).beats)
52
+ ? ((response as BeatListResponse).beats as unknown[])
53
+ : [];
54
+
55
+ return candidate
56
+ .map(normalizeNarrativeBeat)
57
+ .filter((beat): beat is NarrativeBeat => beat !== null);
58
+ }
@@ -1,16 +1,63 @@
1
- import type { CeremonyLog } from "@/lib/types";
1
+ import type { CeremonyLog, CeremonyType, DirectionName } from "@/lib/types";
2
2
 
3
3
  interface CeremonyListResponse {
4
4
  ceremonies?: unknown;
5
5
  }
6
6
 
7
- export function extractCeremonyLogs(response: unknown): CeremonyLog[] {
8
- if (Array.isArray(response)) return response as CeremonyLog[];
9
-
10
- if (!response || typeof response !== "object") return [];
7
+ const DIRECTION_NAMES = new Set<DirectionName>(["east", "south", "west", "north"]);
11
8
 
12
- const candidate = response as CeremonyListResponse;
13
- return Array.isArray(candidate.ceremonies)
14
- ? (candidate.ceremonies as CeremonyLog[])
9
+ function asStringArray(value: unknown): string[] {
10
+ return Array.isArray(value)
11
+ ? value.filter((entry): entry is string => typeof entry === "string")
15
12
  : [];
16
13
  }
14
+
15
+ /**
16
+ * Repair one stored ceremony into a shape the timeline can render.
17
+ *
18
+ * Returns null only when the record carries no identity — a ceremony with no
19
+ * id or type cannot be opened, filtered, or spoken about. Everything else is
20
+ * filled in: the timeline reads `.length` on participants, medicines, and
21
+ * intentions, and sorts on `timestamp`, so a ceremony logged before any of
22
+ * those fields existed takes the whole page down rather than rendering thin.
23
+ */
24
+ export function normalizeCeremonyLog(value: unknown): CeremonyLog | null {
25
+ if (!value || typeof value !== "object") return null;
26
+
27
+ const ceremony = value as Record<string, unknown>;
28
+ if (typeof ceremony.id !== "string" || typeof ceremony.type !== "string") {
29
+ return null;
30
+ }
31
+
32
+ return {
33
+ id: ceremony.id,
34
+ type: ceremony.type as CeremonyType,
35
+ direction:
36
+ typeof ceremony.direction === "string" &&
37
+ DIRECTION_NAMES.has(ceremony.direction as DirectionName)
38
+ ? (ceremony.direction as DirectionName)
39
+ : "east",
40
+ participants: asStringArray(ceremony.participants),
41
+ medicines_used: asStringArray(ceremony.medicines_used),
42
+ intentions: asStringArray(ceremony.intentions),
43
+ timestamp:
44
+ typeof ceremony.timestamp === "string"
45
+ ? ceremony.timestamp
46
+ : new Date(0).toISOString(),
47
+ ...(typeof ceremony.research_context === "string"
48
+ ? { research_context: ceremony.research_context }
49
+ : {}),
50
+ };
51
+ }
52
+
53
+ export function extractCeremonyLogs(response: unknown): CeremonyLog[] {
54
+ const candidate = Array.isArray(response)
55
+ ? response
56
+ : response && typeof response === "object" && Array.isArray((response as CeremonyListResponse).ceremonies)
57
+ ? ((response as CeremonyListResponse).ceremonies as unknown[])
58
+ : [];
59
+
60
+ return candidate
61
+ .map(normalizeCeremonyLog)
62
+ .filter((ceremony): ceremony is CeremonyLog => ceremony !== null);
63
+ }
@@ -67,6 +67,10 @@ interface StoredBeat {
67
67
  timestamp: string;
68
68
  act: number;
69
69
  relations_honored: string[];
70
+ cycle_id?: string;
71
+ parent_beat_id?: string;
72
+ sub_beats?: string[];
73
+ origin?: { producer: string; source_ref?: string; method?: string };
70
74
  }
71
75
 
72
76
  interface StoredCycle {
@@ -114,28 +118,81 @@ interface StoredMmot {
114
118
  // Inside the lock, flush performs read-modify-write so concurrent
115
119
  // writers merge rather than clobber each other.
116
120
 
121
+ function isProcessAlive(pid: number): boolean {
122
+ try {
123
+ // Signal 0 checks process existence without sending a real signal.
124
+ process.kill(pid, 0);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /** Read the owner token stamped in a lock file, or null when unreadable. */
132
+ function readLockOwner(lockPath: string): string | null {
133
+ try {
134
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8').trim()) as { token?: unknown };
135
+ return typeof parsed.token === 'string' ? parsed.token : null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * A lock is stale only when its owner is provably gone.
143
+ *
144
+ * Age alone is not evidence: a slow but living writer holding the lock past
145
+ * the grace period would have it reaped from under it, and two processes
146
+ * would then rewrite the same file from divergent snapshots — the second
147
+ * rename silently discards the first one's records.
148
+ */
149
+ function isLockStale(lockPath: string): boolean {
150
+ let mtimeMs: number;
151
+ try {
152
+ mtimeMs = fs.statSync(lockPath).mtimeMs;
153
+ } catch {
154
+ return false; // Lock already gone — nothing to reap.
155
+ }
156
+
157
+ try {
158
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8').trim()) as { pid?: unknown };
159
+ if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) return false;
160
+ } catch {
161
+ // Locks written by older builds (and by the mcp/ copy of this store) carry
162
+ // no payload, so they name no owner — fall through to the age check.
163
+ }
164
+
165
+ return Date.now() - mtimeMs > 30_000;
166
+ }
167
+
117
168
  function withWriteLock<T>(filePath: string, fn: () => T): T {
118
169
  const lockPath = filePath + '.lock';
170
+ const ownerToken = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
171
+ const payload = JSON.stringify({
172
+ token: ownerToken,
173
+ pid: process.pid,
174
+ created_at: new Date().toISOString(),
175
+ });
119
176
  let locked = false;
120
177
 
121
- // Stale lock recovery: if a previous process crashed while holding the lock,
122
- // the .lock file persists forever. Remove it if older than 30 seconds.
123
- try {
124
- const stat = fs.statSync(lockPath);
125
- if (Date.now() - stat.mtimeMs > 30_000) {
126
- console.error(`[jsonl-store] Removing stale lock: ${lockPath} (age: ${Math.round((Date.now() - stat.mtimeMs) / 1000)}s)`);
127
- fs.unlinkSync(lockPath);
128
- }
129
- } catch { /* lock file doesn't exist — normal */ }
130
-
131
178
  for (let attempt = 0; attempt < 20; attempt++) {
132
179
  try {
133
180
  const fd = fs.openSync(lockPath, 'wx'); // O_EXCL — fails if exists
134
- fs.closeSync(fd);
181
+ try {
182
+ fs.writeFileSync(fd, payload, 'utf-8');
183
+ } finally {
184
+ fs.closeSync(fd);
185
+ }
135
186
  locked = true;
136
187
  break;
137
188
  } catch {
138
- // Lock held by another process; spin-wait with linear back-off
189
+ // Lock held elsewhere. Reap it only once its owner is provably gone,
190
+ // otherwise spin-wait with linear back-off.
191
+ if (isLockStale(lockPath)) {
192
+ console.error(`[jsonl-store] Removing stale lock: ${lockPath}`);
193
+ try { fs.unlinkSync(lockPath); } catch { /* another writer got there first */ }
194
+ continue;
195
+ }
139
196
  const delayMs = Math.min(25 * (attempt + 1), 250);
140
197
  const deadline = Date.now() + delayMs;
141
198
  while (Date.now() < deadline) { /* spin */ }
@@ -149,20 +206,25 @@ function withWriteLock<T>(filePath: string, fn: () => T): T {
149
206
  try {
150
207
  return fn();
151
208
  } finally {
152
- try { fs.unlinkSync(lockPath); } catch { /* best effort */ }
209
+ // Release only the lock we still hold: if a reaper judged us dead and
210
+ // handed the lock on, unlinking would release someone else's write.
211
+ try {
212
+ if (readLockOwner(lockPath) === ownerToken) fs.unlinkSync(lockPath);
213
+ } catch { /* best effort */ }
153
214
  }
154
215
  }
155
216
 
156
217
  // ── JSONL File Helpers ──
157
218
 
158
219
  /**
159
- * Read all records from a JSONL file.
160
- * Returns [] if the file does not exist (normal for first run).
220
+ * Read all records from a JSONL file, keeping the raw text of any line that
221
+ * would not parse so a caller about to rewrite the file can set it aside.
222
+ * Returns no records if the file does not exist (normal for first run).
161
223
  * Throws on permission errors or other real FS failures so the caller
162
224
  * knows not to proceed with a potentially-stale empty state.
163
225
  */
164
- function readJsonl<T>(filePath: string): T[] {
165
- if (!fs.existsSync(filePath)) return [];
226
+ function readJsonlWithMalformed<T>(filePath: string): { records: T[]; malformed: string[] } {
227
+ if (!fs.existsSync(filePath)) return { records: [], malformed: [] };
166
228
 
167
229
  let content: string;
168
230
  try {
@@ -173,6 +235,7 @@ function readJsonl<T>(filePath: string): T[] {
173
235
  }
174
236
 
175
237
  const records: T[] = [];
238
+ const malformed: string[] = [];
176
239
  for (const line of content.split('\n')) {
177
240
  const trimmed = line.trim();
178
241
  if (!trimmed) continue;
@@ -182,9 +245,37 @@ function readJsonl<T>(filePath: string): T[] {
182
245
  // Skip individual malformed lines — log so they don't disappear silently
183
246
  const message = error instanceof Error ? error.message : String(error);
184
247
  console.warn(`[jsonl-store] Skipping malformed line in ${filePath}: ${message}`);
248
+ malformed.push(trimmed);
185
249
  }
186
250
  }
187
- return records;
251
+ return { records, malformed };
252
+ }
253
+
254
+ function readJsonl<T>(filePath: string): T[] {
255
+ return readJsonlWithMalformed<T>(filePath).records;
256
+ }
257
+
258
+ /**
259
+ * Set unparseable lines aside before a rewrite erases them.
260
+ *
261
+ * flush() rebuilds the whole file from the records it could parse, so a line
262
+ * readJsonl skipped — a half-written record from a killed writer, a truncated
263
+ * tail — is gone for good the moment anything else is saved. Appending the raw
264
+ * text to a sidecar keeps it recoverable by hand. Nothing reads the sidecar
265
+ * back; it exists so the loss is not silent.
266
+ */
267
+ function quarantineMalformedLines(filePath: string, malformed: string[]): void {
268
+ if (malformed.length === 0) return;
269
+
270
+ const quarantinePath = `${filePath}.quarantine`;
271
+ try {
272
+ fs.appendFileSync(quarantinePath, malformed.join('\n') + '\n', 'utf-8');
273
+ console.error(`[jsonl-store] Quarantined ${malformed.length} unparseable line(s) from ${filePath} to ${quarantinePath}`);
274
+ } catch (error) {
275
+ // Best effort — a failed sidecar must not block the write itself.
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ console.warn(`[jsonl-store] Failed to quarantine malformed lines from ${filePath}: ${message}`);
278
+ }
188
279
  }
189
280
 
190
281
  /**
@@ -249,7 +340,8 @@ class JsonlCollection<T extends { id?: string }> {
249
340
  private flush(): void {
250
341
  withWriteLock(this.filePath, () => {
251
342
  // Read disk state inside the lock so we don't clobber concurrent writes
252
- const diskRecords = readJsonl<T>(this.filePath);
343
+ const { records: diskRecords, malformed } = readJsonlWithMalformed<T>(this.filePath);
344
+ quarantineMalformedLines(this.filePath, malformed);
253
345
  const merged = new Map<string, T>();
254
346
  for (const r of diskRecords) {
255
347
  const id = (r as any).id as string | undefined;
@@ -326,8 +418,11 @@ class EdgeCollection {
326
418
  }
327
419
 
328
420
  private edgeKey(edge: StoredEdge): string {
329
- // Use explicit id if present, otherwise derive from endpoints
330
- return (edge as any).id || `${edge.from_id}:${edge.to_id}`;
421
+ // JSON-array encoding of the endpoints, matching @medicine-wheel/storage-provider.
422
+ // A plain `from:to` join collides whenever an id carries the delimiter — the
423
+ // store already holds ids like `memory:1775381859564:e2e` — and the collision
424
+ // silently overwrites one relation with an unrelated one.
425
+ return JSON.stringify([edge.from_id, edge.to_id]);
331
426
  }
332
427
 
333
428
  private sync(): void {
@@ -346,7 +441,8 @@ class EdgeCollection {
346
441
  private flush(): void {
347
442
  withWriteLock(this.filePath, () => {
348
443
  // Read-modify-write inside lock to merge concurrent changes
349
- const diskRecords = readJsonl<StoredEdge>(this.filePath);
444
+ const { records: diskRecords, malformed } = readJsonlWithMalformed<StoredEdge>(this.filePath);
445
+ quarantineMalformedLines(this.filePath, malformed);
350
446
  const merged = new Map<string, StoredEdge>();
351
447
  for (const r of diskRecords) {
352
448
  merged.set(this.edgeKey(r), r);
package/lib/store.ts CHANGED
@@ -22,6 +22,8 @@ import type {
22
22
  MedicineWheelCycle,
23
23
  } from '@/lib/types';
24
24
 
25
+ import { actForDirection } from '@medicine-wheel/narrative-engine';
26
+ import { extractBeats } from './beat-response';
25
27
  import { extractCycles, normalizeMedicineWheelCycle } from './cycle-response';
26
28
  import { getJsonlStore } from './jsonl-store';
27
29
 
@@ -123,14 +125,22 @@ export function createCeremony(data: Omit<CeremonyLog, 'id' | 'timestamp'> & { i
123
125
  // ── Narrative Beats ──
124
126
 
125
127
  export function getAllBeats(): NarrativeBeat[] {
126
- return store.getAllBeats() as unknown as NarrativeBeat[];
128
+ // Beats written before ceremonies/learnings/relations_honored existed reach
129
+ // readers as bare records; normalizing here keeps one legacy line from
130
+ // taking down every arc that reads the collection.
131
+ return extractBeats(store.getAllBeats());
127
132
  }
128
133
 
129
134
  export function getBeatsByDirection(direction: string): NarrativeBeat[] {
130
- return store.getBeatsByDirection(direction) as unknown as NarrativeBeat[];
135
+ return extractBeats(store.getBeatsByDirection(direction));
131
136
  }
132
137
 
133
- export function createBeat(data: Omit<NarrativeBeat, 'id' | 'timestamp'> & { id?: string; timestamp?: string }): NarrativeBeat {
138
+ // `act` is optional because it is derived from `direction`, not supplied.
139
+ // Requiring it invited callers to pass a constant, which is how a west beat
140
+ // came to be recorded in act 1.
141
+ export function createBeat(
142
+ data: Omit<NarrativeBeat, 'id' | 'timestamp' | 'act'> & { id?: string; timestamp?: string; act?: number },
143
+ ): NarrativeBeat {
134
144
  const id = data.id || crypto.randomUUID();
135
145
  const beat: NarrativeBeat = {
136
146
  id,
@@ -141,10 +151,36 @@ export function createBeat(data: Omit<NarrativeBeat, 'id' | 'timestamp'> & { id?
141
151
  ceremonies: data.ceremonies ?? [],
142
152
  learnings: data.learnings ?? [],
143
153
  timestamp: data.timestamp || new Date().toISOString(),
144
- act: data.act ?? 1,
154
+ // Derive the act from the direction rather than defaulting to 1. A west
155
+ // beat posted without an act was being recorded as an opening moment,
156
+ // which places it wrongly on the wheel for every reader downstream.
157
+ act: data.act ?? actForDirection(data.direction),
145
158
  relations_honored: data.relations_honored ?? [],
146
159
  };
160
+ if (data.cycle_id !== undefined) beat.cycle_id = data.cycle_id;
161
+ if (data.parent_beat_id !== undefined) beat.parent_beat_id = data.parent_beat_id;
162
+ if (data.sub_beats !== undefined) beat.sub_beats = data.sub_beats;
163
+ if (data.origin !== undefined) beat.origin = data.origin;
164
+
147
165
  store.createBeat(beat as any);
166
+
167
+ // Bind the cycle side of the relation. A beat naming a cycle that does not
168
+ // list it back is invisible to every arc reader that starts from the cycle.
169
+ if (beat.cycle_id) {
170
+ const cycle = store.getCycle(beat.cycle_id) as any;
171
+ if (cycle && !(cycle.beats ?? []).includes(beat.id)) {
172
+ store.createCycle({ ...cycle, beats: [...(cycle.beats ?? []), beat.id] });
173
+ }
174
+ }
175
+
176
+ // Likewise the parent side of a telescoped beat.
177
+ if (beat.parent_beat_id) {
178
+ const parent = store.getBeat(beat.parent_beat_id) as any;
179
+ if (parent && !(parent.sub_beats ?? []).includes(beat.id)) {
180
+ store.createBeat({ ...parent, sub_beats: [...(parent.sub_beats ?? []), beat.id] });
181
+ }
182
+ }
183
+
148
184
  return beat;
149
185
  }
150
186
 
@@ -172,6 +208,36 @@ export function createCycle(data: { research_question: string; current_direction
172
208
  return normalizeMedicineWheelCycle(cycle) ?? cycle;
173
209
  }
174
210
 
211
+ /**
212
+ * Write a cycle under an id the caller already holds.
213
+ *
214
+ * When the id names a stored cycle this amends it, merging the patch over the
215
+ * existing record. When it names none, the cycle is created under that id
216
+ * rather than rejected: a client that minted its own id and is now telling us
217
+ * about it is opening a cycle, not amending a missing one. Rejecting it left
218
+ * MCP's create_research_cycle reporting a cycle_id for a cycle that was never
219
+ * stored — the same divergence in a louder costume.
220
+ */
221
+ export function upsertCycle(patch: { id: string } & Partial<MedicineWheelCycle>): MedicineWheelCycle {
222
+ const existing = store.getCycle(patch.id) as any;
223
+
224
+ const base = existing ?? {
225
+ id: patch.id,
226
+ research_question: '',
227
+ start_date: new Date().toISOString(),
228
+ current_direction: 'east',
229
+ beats: [],
230
+ ceremonies_conducted: 0,
231
+ relations_mapped: 0,
232
+ wilson_alignment: 0,
233
+ ocap_compliant: false,
234
+ };
235
+
236
+ const merged = { ...base, ...patch, id: patch.id };
237
+ store.createCycle(merged);
238
+ return normalizeMedicineWheelCycle(merged) ?? merged;
239
+ }
240
+
175
241
  // ── Seed Data ──
176
242
 
177
243
  export function seedDemoData() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -20,9 +20,13 @@
20
20
  "Dockerfile",
21
21
  "docker-entrypoint.sh",
22
22
  "docker-build-push.sh",
23
- "README.md"
23
+ "README.md",
24
+ "ALPHA.md"
24
25
  ],
25
26
  "workspaces": [
27
+ "src/brainstorming",
28
+ "src/creative-orientation",
29
+ "src/gap-analysis",
26
30
  "src/ceremony-protocol",
27
31
  "src/community-review",
28
32
  "src/consent-lifecycle",
@@ -52,9 +56,9 @@
52
56
  "clean:packages": "npm run clean --workspaces --if-present",
53
57
  "prebuild": "npm run build:packages",
54
58
  "predev": "npm run build:packages",
55
- "dev": "next dev -p 3940",
59
+ "dev": "next dev -p 8040",
56
60
  "build": "next build",
57
- "start": "next start -p 3940",
61
+ "start": "next start -p 8040",
58
62
  "lint": "next lint",
59
63
  "test": "vitest run",
60
64
  "test:watch": "vitest",
@@ -78,29 +82,30 @@
78
82
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
79
83
  },
80
84
  "dependencies": {
81
- "@medicine-wheel/ceremonial-diary": "^0.5.1",
82
- "@medicine-wheel/ceremony-protocol": "^0.5.1",
83
- "@medicine-wheel/community-review": "^0.5.1",
84
- "@medicine-wheel/consent-lifecycle": "^0.5.1",
85
- "@medicine-wheel/data-store": "^0.5.1",
86
- "@medicine-wheel/data-store-postgres": "^0.5.1",
87
- "@medicine-wheel/fire-keeper": "^0.5.1",
88
- "@medicine-wheel/github-ceremony": "^0.5.1",
89
- "@medicine-wheel/graph-viz": "^0.5.1",
90
- "@medicine-wheel/importance-unit": "^0.5.1",
91
- "@medicine-wheel/mcp": "^4.5.0",
92
- "@medicine-wheel/narrative-cluster": "^0.5.1",
93
- "@medicine-wheel/narrative-engine": "^0.5.1",
94
- "@medicine-wheel/ontology-core": "^0.5.1",
95
- "@medicine-wheel/perception-layer": "^0.5.1",
96
- "@medicine-wheel/prompt-decomposition": "^0.5.1",
97
- "@medicine-wheel/relational-index": "^0.5.1",
98
- "@medicine-wheel/relational-query": "^0.5.1",
99
- "@medicine-wheel/session-reader": "^0.5.1",
100
- "@medicine-wheel/storage-provider": "^0.5.1",
101
- "@medicine-wheel/transformation-tracker": "^0.5.1",
102
- "@medicine-wheel/ui-components": "^0.5.1",
85
+ "@medicine-wheel/ceremonial-diary": "^0.5.3",
86
+ "@medicine-wheel/ceremony-protocol": "^0.5.3",
87
+ "@medicine-wheel/community-review": "^0.5.3",
88
+ "@medicine-wheel/consent-lifecycle": "^0.5.3",
89
+ "@medicine-wheel/data-store": "^0.5.3",
90
+ "@medicine-wheel/data-store-postgres": "^0.5.3",
91
+ "@medicine-wheel/fire-keeper": "^0.5.3",
92
+ "@medicine-wheel/github-ceremony": "^0.5.3",
93
+ "@medicine-wheel/graph-viz": "^0.5.3",
94
+ "@medicine-wheel/importance-unit": "^0.5.3",
95
+ "@medicine-wheel/mcp": "^4.5.3",
96
+ "@medicine-wheel/narrative-cluster": "^0.5.3",
97
+ "@medicine-wheel/narrative-engine": "^0.5.3",
98
+ "@medicine-wheel/ontology-core": "^0.5.3",
99
+ "@medicine-wheel/perception-layer": "^0.5.3",
100
+ "@medicine-wheel/prompt-decomposition": "^0.5.3",
101
+ "@medicine-wheel/relational-index": "^0.5.3",
102
+ "@medicine-wheel/relational-query": "^0.5.3",
103
+ "@medicine-wheel/session-reader": "^0.5.3",
104
+ "@medicine-wheel/storage-provider": "^0.5.3",
105
+ "@medicine-wheel/transformation-tracker": "^0.5.3",
106
+ "@medicine-wheel/ui-components": "^0.5.3",
103
107
  "@neondatabase/serverless": "^0.10.0",
108
+ "@xyflow/react": "^12.3.0",
104
109
  "clsx": "^2.1.1",
105
110
  "lucide-react": "^0.475.0",
106
111
  "next": "^15.3.0",
@@ -110,7 +115,7 @@
110
115
  "recharts": "^2.15.4",
111
116
  "sonner": "^1.7.0",
112
117
  "tailwind-merge": "^3.0.2",
113
- "@xyflow/react": "^12.3.0"
118
+ "zod": "^3.23.0"
114
119
  },
115
120
  "devDependencies": {
116
121
  "@tailwindcss/postcss": "^4.1.0",
@@ -121,5 +126,9 @@
121
126
  "tailwindcss": "^4.1.0",
122
127
  "typescript": "^5.7.0",
123
128
  "vitest": "^4.1.8"
129
+ },
130
+ "repository": {
131
+ "type": "git",
132
+ "url": "git+https://github.com/jgwill/medicine-wheel.git"
124
133
  }
125
134
  }