@medicine-wheel/app 0.5.3 → 0.5.4

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
@@ -31,8 +31,17 @@ medicine-wheel-ontology-core ← Foundation (types, schemas, RDF vocabu
31
31
  ├── medicine-wheel-ui-components ← React components
32
32
  ├── medicine-wheel-data-store ← Shared Redis data access
33
33
  └── medicine-wheel-session-reader ← Session event data reader
34
+
35
+ @medicine-wheel/creative-orientation ← The question asked before the work
36
+ ├── @medicine-wheel/creative-problem-solving ← Signpost — re-exports the above
37
+ ├── @medicine-wheel/gap-analysis ← The fire path
38
+ └── @medicine-wheel/brainstorming ← Idea → design, through human gates
34
39
  ```
35
40
 
41
+ > **Is there a prior state you are restoring?** Named one — this is a fire, and
42
+ > gap analysis is the right instrument. Named none — you are creating, and
43
+ > structural tension is. `mw orient "<outcome>"` asks it from the command line.
44
+
36
45
  ## Methodology: RISE Framework
37
46
 
38
47
  This project utilizes the **RISE** methodology:
@@ -135,6 +144,30 @@ Session event reader — JSONL parsing, session summaries, analytics extraction,
135
144
  - **Version:** 0.2.0
136
145
  - **Dependencies:** None (Node.js built-ins only)
137
146
 
147
+ ### [@medicine-wheel/creative-orientation](src/creative-orientation)
148
+ 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.
149
+
150
+ - **Version:** 0.5.3
151
+ - **Dependencies:** None (Node.js built-ins only)
152
+
153
+ ### [@medicine-wheel/creative-problem-solving](src/creative-problem-solving)
154
+ Signpost package — re-exports `@medicine-wheel/creative-orientation` and adds `THE_QUESTION`. Holds no logic of its own. It exists because "creative problem solving" is the name people look for, and a signpost carrying the traveller's name is how they find the door.
155
+
156
+ - **Version:** 0.5.3
157
+ - **Dependencies:** `@medicine-wheel/creative-orientation`
158
+
159
+ ### [@medicine-wheel/gap-analysis](src/gap-analysis)
160
+ Problem-solving built properly for when something worked and stopped — evidenced baseline, observation, difference, verifiable elimination steps. Root cause, incidents, regressions, troubleshooting. The baseline requirement is what separates a fire from a creating act.
161
+
162
+ - **Version:** 0.5.3
163
+ - **Dependencies:** `@medicine-wheel/creative-orientation`
164
+
165
+ ### [@medicine-wheel/brainstorming](src/brainstorming)
166
+ Idea into committed design through approval gates a human holds — `explore → clarify → approaches → design → spec → review → plan`. Every outcome it emits, including its own multiple-choice questions, passes the orientation question before it is spoken.
167
+
168
+ - **Version:** 0.5.3
169
+ - **Dependencies:** `@medicine-wheel/creative-orientation`
170
+
138
171
  ## Specifications
139
172
 
140
173
  RISE framework specifications for all 15 packages are in [`rispecs/`](rispecs/). Start with [`medicine-wheel.spec.md`](rispecs/medicine-wheel.spec.md) for the system overview.
@@ -15,7 +15,7 @@ export async function POST(request: Request) {
15
15
  // Pass the caller's id and timestamp through when supplied. A client that
16
16
  // authored a beat holds its id and will look it up again — minting a fresh
17
17
  // one here hands back a beat the caller can never find.
18
- const beat = createBeat({
18
+ const { beat, warnings } = createBeat({
19
19
  id: body.id,
20
20
  timestamp: body.timestamp,
21
21
  direction: body.direction,
@@ -31,8 +31,15 @@ export async function POST(request: Request) {
31
31
  sub_beats: body.sub_beats,
32
32
  origin: body.origin,
33
33
  });
34
- return NextResponse.json(beat, { status: 201 });
34
+ // Warnings ride on the created beat rather than replacing it, so clients
35
+ // that read the beat back by id keep working while advisory findings stop
36
+ // being computed-and-discarded.
37
+ return NextResponse.json(warnings.length ? { ...beat, warnings } : beat, { status: 201 });
35
38
  } catch (error: any) {
36
- return NextResponse.json({ error: error.message }, { status: 500 });
39
+ // An invalid draft is the caller's error, not the server's. Reporting it
40
+ // as a 500 would read as "we broke" when the truth is "this beat does not
41
+ // satisfy the wheel's laws and was not stored".
42
+ const invalid = /^Invalid beat draft/.test(error?.message ?? '');
43
+ return NextResponse.json({ error: error.message }, { status: invalid ? 400 : 500 });
37
44
  }
38
45
  }
@@ -25,23 +25,32 @@ export default function BeatsPage() {
25
25
  async function addBeat(e: FormEvent<HTMLFormElement>) {
26
26
  e.preventDefault();
27
27
  const form = new FormData(e.currentTarget);
28
+ // The act is never sent: it is derived from the direction by the engine.
29
+ // Offering it as a form field is how a west beat came to be stored in act 1.
28
30
  const body = {
29
31
  direction: form.get("direction") as string,
30
32
  title: form.get("title") as string,
31
33
  description: form.get("description") as string,
32
34
  prose: form.get("prose") as string || undefined,
33
- act: parseInt(form.get("act") as string) || 1,
34
35
  learnings: (form.get("learnings") as string).split(",").map((s) => s.trim()).filter(Boolean),
35
36
  ceremonies: [],
36
37
  relations_honored: [],
37
38
  };
38
39
  const res = await fetch("/api/narrative/beats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
39
40
  if (res.ok) {
40
- toast.success("Beat added");
41
+ const created = await res.json();
42
+ if (Array.isArray(created?.warnings) && created.warnings.length) {
43
+ toast.warning(`Beat added — ${created.warnings.join(" · ")}`);
44
+ } else {
45
+ toast.success("Beat added");
46
+ }
41
47
  setShowForm(false);
42
48
  const data = await fetch("/api/narrative/beats").then((r) => r.json());
43
49
  setBeats(Array.isArray(data) ? data : []);
44
- } else { toast.error("Could not save the beat — check the form and try again"); }
50
+ } else {
51
+ const err = await res.json().catch(() => null);
52
+ toast.error(err?.error ?? "Could not save the beat — check the form and try again");
53
+ }
45
54
  }
46
55
 
47
56
  // Direction summary
@@ -87,9 +96,7 @@ export default function BeatsPage() {
87
96
  </select>
88
97
  <textarea name="description" placeholder="Description" rows={2} required className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
89
98
  <textarea name="prose" placeholder="Prose (optional)" rows={2} className="px-3 py-2 rounded-md border bg-background text-sm" />
90
- <select name="act" className="px-3 py-2 rounded-md border bg-background text-sm">
91
- <option value="1">Act 1 (East)</option><option value="2">Act 2 (South)</option><option value="3">Act 3 (West)</option><option value="4">Act 4 (North)</option>
92
- </select>
99
+ <p className="px-3 py-2 text-xs text-muted-foreground self-center">Act is derived from the direction — east 1, south 2, west 3, north 4.</p>
93
100
  <input name="learnings" placeholder="Learnings (comma-separated)" className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
94
101
  <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:opacity-90">Add beat</button>
95
102
  </form>
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.actForDirection = void 0;
37
+ exports.resolveBeatsFile = resolveBeatsFile;
38
+ exports.registerEpisodeBeats = registerEpisodeBeats;
39
+ exports.describeArc = describeArc;
40
+ /**
41
+ * mw beat register — an episode's authored beats into the wheel.
42
+ *
43
+ * This is the path that was missing. `mkepisode --register` registers the
44
+ * episode; nothing registered the beats an episode carries, so they sat in a
45
+ * YAML file as a design artifact and never became records.
46
+ *
47
+ * It goes **through** `@medicine-wheel/narrative-engine` rather than posting
48
+ * JSON at the REST surface. That distinction is the whole point: the first
49
+ * time these beats were registered it was by a script that POSTed directly,
50
+ * which meant `validateBeatDraft` never ran and nothing confirmed they were
51
+ * legal. Registering by hand around the door is the same defect the door
52
+ * exists to prevent.
53
+ *
54
+ * Reads `<episode>/beats/beats.yaml`:
55
+ *
56
+ * cycle_id: cycle-... # null means unbound; the cycle is created if named
57
+ * cycle_question: >- # used only when the cycle must be created
58
+ * beats:
59
+ * - id, direction, title, description, prose?, learnings[], relations_honored[], origin
60
+ */
61
+ const fs = __importStar(require("fs"));
62
+ const path = __importStar(require("path"));
63
+ const narrative_engine_1 = require("@medicine-wheel/narrative-engine");
64
+ Object.defineProperty(exports, "actForDirection", { enumerable: true, get: function () { return narrative_engine_1.actForDirection; } });
65
+ /** Minimal YAML reader for the beats file — avoids a dependency for one shape. */
66
+ function readBeatsFile(file) {
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
69
+ const yaml = require('js-yaml');
70
+ const parsed = yaml.load(fs.readFileSync(file, 'utf8'));
71
+ if (!parsed || typeof parsed !== 'object') {
72
+ throw new Error(`${file} did not parse to an object`);
73
+ }
74
+ const { cycle_id = null, cycle_question, beats } = parsed;
75
+ if (!Array.isArray(beats))
76
+ throw new Error(`${file} has no 'beats' array`);
77
+ return { cycle_id, cycle_question, beats };
78
+ }
79
+ function resolveBeatsFile(episodeDir) {
80
+ const candidates = [
81
+ path.join(episodeDir, 'beats', 'beats.yaml'),
82
+ path.join(episodeDir, 'beats.yaml'),
83
+ ];
84
+ const found = candidates.find(c => fs.existsSync(c));
85
+ if (!found) {
86
+ throw new Error(`No beats file found. Looked for:\n ${candidates.join('\n ')}`);
87
+ }
88
+ return found;
89
+ }
90
+ async function get(url) {
91
+ const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
92
+ if (!res.ok)
93
+ throw new Error(`HTTP ${res.status} GET ${url}`);
94
+ return res.json();
95
+ }
96
+ async function post(url, body) {
97
+ const res = await fetch(url, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify(body),
101
+ signal: AbortSignal.timeout(20000),
102
+ });
103
+ const text = await res.text();
104
+ if (!res.ok) {
105
+ // A 400 is the wheel refusing an illegal beat. Surface its reason rather
106
+ // than a status code — the caller needs to know which law it broke.
107
+ let detail = text;
108
+ try {
109
+ detail = JSON.parse(text).error ?? text;
110
+ }
111
+ catch { /* keep raw */ }
112
+ throw new Error(`HTTP ${res.status}: ${detail}`);
113
+ }
114
+ return JSON.parse(text);
115
+ }
116
+ async function registerEpisodeBeats(episodeDir, options) {
117
+ const file = resolveBeatsFile(episodeDir);
118
+ const doc = readBeatsFile(file);
119
+ const outcome = {
120
+ registered: [], skipped: [], rejected: [],
121
+ cycleId: doc.cycle_id, cycleCreated: false,
122
+ };
123
+ // Build every draft through the door first. Nothing is written until all of
124
+ // them are legal — a half-registered arc is worse than none, because the
125
+ // cycle then reports a story that stops mid-sentence.
126
+ const drafts = [];
127
+ for (const raw of doc.beats) {
128
+ const draft = {
129
+ id: raw.id,
130
+ direction: raw.direction,
131
+ title: (raw.title ?? '').trim(),
132
+ description: (raw.description ?? '').trim(),
133
+ prose: raw.prose ? String(raw.prose).trim() : undefined,
134
+ learnings: raw.learnings ?? [],
135
+ ceremonies: raw.ceremonies ?? [],
136
+ relations_honored: raw.relations_honored ?? [],
137
+ cycle_id: doc.cycle_id ?? undefined,
138
+ parent_beat_id: raw.parent_beat_id,
139
+ origin: raw.origin ?? {
140
+ producer: 'chronicle-episode',
141
+ source_ref: path.basename(episodeDir),
142
+ method: 'authored',
143
+ },
144
+ };
145
+ const check = (0, narrative_engine_1.validateBeatDraft)(draft);
146
+ if (!check.valid) {
147
+ outcome.rejected.push({
148
+ id: raw.id ?? '(no id)',
149
+ violations: check.violations.filter(v => v.severity === 'error').map(v => `${v.field}: ${v.message}`),
150
+ });
151
+ continue;
152
+ }
153
+ // Prove it can be authored before anything is sent.
154
+ (0, narrative_engine_1.createBeat)(draft);
155
+ drafts.push(draft);
156
+ }
157
+ if (outcome.rejected.length > 0 || options.dryRun)
158
+ return outcome;
159
+ // Ensure the cycle exists before binding to it, so no beat is written naming
160
+ // a cycle that is not there.
161
+ if (doc.cycle_id) {
162
+ const cycles = await get(`${options.apiUrl}/api/narrative/cycles`);
163
+ if (!cycles.some(c => c.id === doc.cycle_id)) {
164
+ await post(`${options.apiUrl}/api/narrative/cycles`, {
165
+ id: doc.cycle_id,
166
+ research_question: doc.cycle_question ?? `Episode ${path.basename(episodeDir)}`,
167
+ current_direction: 'east',
168
+ });
169
+ outcome.cycleCreated = true;
170
+ }
171
+ }
172
+ const existing = await get(`${options.apiUrl}/api/narrative/beats`);
173
+ const known = new Set(existing.map(b => b.id));
174
+ for (const draft of drafts) {
175
+ if (draft.id && known.has(draft.id)) {
176
+ outcome.skipped.push({ id: draft.id, reason: 'already registered' });
177
+ continue;
178
+ }
179
+ const got = await post(`${options.apiUrl}/api/narrative/beats`, draft);
180
+ if (got.id !== draft.id) {
181
+ throw new Error(`The wheel returned id ${got.id} for a beat sent as ${draft.id}. ` +
182
+ 'Registration stopped — an id that does not survive the round trip cannot be looked up again.');
183
+ }
184
+ outcome.registered.push(got.id);
185
+ }
186
+ return outcome;
187
+ }
188
+ /** Human-readable summary of what an arc now holds. */
189
+ function describeArc(beats) {
190
+ const order = { east: 1, south: 2, west: 3, north: 4 };
191
+ const lines = beats
192
+ .slice()
193
+ .sort((a, b) => (a.act - b.act) || String(a.timestamp).localeCompare(String(b.timestamp)))
194
+ .map(b => ` act ${b.act} ${String(b.direction).padEnd(6)} ${b.title}`);
195
+ const walked = [...new Set(beats.map(b => b.direction))].sort((a, b) => order[a] - order[b]);
196
+ lines.push('');
197
+ lines.push(` directions walked: ${walked.join(' → ')}${walked.length === 4 ? ' (complete arc)' : ''}`);
198
+ return lines;
199
+ }
package/dist/cli/mw.js CHANGED
@@ -46,6 +46,8 @@ const child_process_1 = require("child_process");
46
46
  const path = __importStar(require("path"));
47
47
  const fs = __importStar(require("fs"));
48
48
  const skills_1 = require("./skills");
49
+ const beats_register_1 = require("./beats-register");
50
+ const orientation_1 = require("./orientation");
49
51
  // ── Config ────────────────────────────────────────────────────────
50
52
  const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:8040';
51
53
  const MW_FORMAT = process.env.MW_FORMAT ?? 'pretty';
@@ -212,6 +214,20 @@ function cmdHelp() {
212
214
  console.log(`
213
215
  ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
214
216
 
217
+ ORIENTATION
218
+ mw orient "<outcome>" Which orientation does this call for?
219
+ [--restores "<prior state>"] A prior state you are returning to
220
+ [--evidence "<when it existed>"] When that state was actually true
221
+
222
+ Ask before problem-solving, debugging, fixing, troubleshooting, root-cause
223
+ or creative-problem-solving work. It reads the claim you supply:
224
+
225
+ --restores named → a fire; gap analysis is the right instrument
226
+ --restores absent → you are creating; structural tension is
227
+
228
+ Urgency is not evidence — under pressure every situation feels like a fire.
229
+ It reports and offers a restatement; it never refuses.
230
+
215
231
  CEREMONY LIFECYCLE
216
232
  mw ceremony open <intention> Open ceremony (starts in East)
217
233
  mw ceremony close <id> [summary] Close ceremony
@@ -235,6 +251,8 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
235
251
 
236
252
  NARRATIVE
237
253
  mw beat create <dir> <title> <desc> Create narrative beat
254
+ mw beat register <episode-dir> Register authored beats via the door
255
+ mw beat register <episode-dir> --dry-run Check legality, write nothing
238
256
  mw beat list [--direction east] List beats
239
257
  mw arc <cycle_id> Get narrative arc
240
258
 
@@ -525,9 +543,49 @@ async function cmdNode(positional, flags) {
525
543
  }
526
544
  }
527
545
  // ── Beat ──────────────────────────────────────────────────────────
528
- async function cmdBeat(positional) {
546
+ async function cmdBeat(positional, flags = {}) {
529
547
  const sub = positional[0] ?? 'list';
530
548
  switch (sub) {
549
+ case 'register': {
550
+ const dir = positional[1];
551
+ if (!dir) {
552
+ console.error('Usage: mw beat register <episode-dir> [--dry-run]');
553
+ console.error(' Registers an episode\'s authored beats through the authoring door.');
554
+ process.exit(2);
555
+ }
556
+ try {
557
+ const out = await (0, beats_register_1.registerEpisodeBeats)(dir, {
558
+ apiUrl: MW_API_URL,
559
+ dryRun: flags['dry-run'] === true,
560
+ });
561
+ if (out.rejected.length > 0) {
562
+ console.error(`${C.south}${out.rejected.length} beat(s) rejected — nothing was written${C.reset}`);
563
+ for (const r of out.rejected) {
564
+ console.error(` ${r.id}`);
565
+ for (const v of r.violations)
566
+ console.error(` ${v}`);
567
+ }
568
+ process.exit(1);
569
+ }
570
+ if (flags['dry-run'] === true) {
571
+ console.log(`${C.green}dry run — all beats are legal, nothing written${C.reset}`);
572
+ break;
573
+ }
574
+ if (out.cycleCreated)
575
+ console.log(`${C.dim}cycle created:${C.reset} ${out.cycleId}`);
576
+ console.log(`${C.green}registered ${out.registered.length}${C.reset}${out.skipped.length ? ` ${C.dim}(${out.skipped.length} already present)${C.reset}` : ''}`);
577
+ const all = (await api('GET', '/api/narrative/beats'));
578
+ const scoped = out.cycleId ? all.filter(b => b.cycle_id === out.cycleId) : all;
579
+ console.log('');
580
+ for (const line of (0, beats_register_1.describeArc)(scoped))
581
+ console.log(line);
582
+ }
583
+ catch (err) {
584
+ console.error(`${C.south}${err.message}${C.reset}`);
585
+ process.exit(1);
586
+ }
587
+ break;
588
+ }
531
589
  case 'create':
532
590
  mcpCall('create_narrative_beat', {
533
591
  direction: positional[1] ?? '',
@@ -714,7 +772,7 @@ async function main() {
714
772
  break;
715
773
  case 'beat':
716
774
  case 'b':
717
- await cmdBeat(rest);
775
+ await cmdBeat(rest, flags);
718
776
  break;
719
777
  case 'edge':
720
778
  case 'e':
@@ -746,6 +804,10 @@ async function main() {
746
804
  case 'arc':
747
805
  mcpCall('get_narrative_arc', { cycle_id: rest[0] ?? '' });
748
806
  break;
807
+ case 'orient':
808
+ case 'orientation':
809
+ (0, orientation_1.cmdOrient)(rest, flags);
810
+ break;
749
811
  default:
750
812
  console.error(`${C.south}Unknown command: ${cmd}${C.reset}`);
751
813
  console.error("Run 'mw help' for usage.");
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cmdOrient = cmdOrient;
4
+ /**
5
+ * mw orient — which orientation does this situation call for?
6
+ *
7
+ * The operator surface for `@medicine-wheel/creative-orientation`. The package
8
+ * shipped at 0.5.3 with no way to run it, which made it a library nobody could
9
+ * reach — a spec with a package.json.
10
+ *
11
+ * mw orient "catch cross-service contract breaks"
12
+ * mw orient "the image matches the packages" --restores "true through 0.5.0" --evidence "tagged 2026-07-17"
13
+ *
14
+ * It never refuses. It reports, and where the phrasing and the situation
15
+ * disagree it says so and offers a restatement.
16
+ */
17
+ const creative_orientation_1 = require("@medicine-wheel/creative-orientation");
18
+ const C = {
19
+ east: '\x1b[33m', south: '\x1b[31m', west: '\x1b[34m',
20
+ green: '\x1b[32m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m',
21
+ };
22
+ function cmdOrient(positional, flags) {
23
+ const outcome = positional.join(' ').trim();
24
+ if (!outcome) {
25
+ console.error('Usage: mw orient "<the outcome you want>" [--restores "<prior state>"] [--evidence "<when it existed>"]');
26
+ console.error('');
27
+ console.error(' Names which orientation the situation calls for.');
28
+ console.error(' --restores a prior state you are returning to. Its presence is what');
29
+ console.error(' separates a repair from a creating act.');
30
+ process.exit(2);
31
+ }
32
+ const restores = typeof flags.restores === 'string' ? flags.restores : null;
33
+ const evidence = typeof flags.evidence === 'string' ? flags.evidence : undefined;
34
+ const claim = { outcome, restores, evidence };
35
+ const reading = (0, creative_orientation_1.readOrientation)(claim);
36
+ const tint = reading.orientation === 'advancing' ? C.east : C.west;
37
+ console.log('');
38
+ console.log(`${C.bold}${tint}${reading.orientation}${C.reset} → route: ${C.bold}${reading.route}${C.reset}`);
39
+ console.log('');
40
+ console.log(`${C.dim}outcome:${C.reset} ${outcome}`);
41
+ if (restores)
42
+ console.log(`${C.dim}restores:${C.reset} ${restores}${evidence ? ` ${C.dim}(${evidence})${C.reset}` : ''}`);
43
+ console.log('');
44
+ console.log(`${C.dim}signals${C.reset}`);
45
+ for (const s of reading.signals) {
46
+ const mark = s.suggests === 'advancing' ? `${C.east}↑${C.reset}` : `${C.west}↻${C.reset}`;
47
+ console.log(` ${mark} ${C.dim}[${s.kind}]${C.reset} ${s.detail}`);
48
+ }
49
+ if ((0, creative_orientation_1.worthSaying)(reading)) {
50
+ console.log('');
51
+ console.log(`${C.south}${C.bold}advice${C.reset}`);
52
+ console.log(` ${reading.advice}`);
53
+ if (reading.suggestedOutcome) {
54
+ console.log('');
55
+ console.log(`${C.green}suggested restatement${C.reset}`);
56
+ console.log(` ${reading.suggestedOutcome}`);
57
+ }
58
+ }
59
+ else {
60
+ console.log('');
61
+ console.log(`${C.green}the phrasing and the situation agree — nothing to say${C.reset}`);
62
+ }
63
+ console.log('');
64
+ }
package/lib/store.ts CHANGED
@@ -22,7 +22,7 @@ import type {
22
22
  MedicineWheelCycle,
23
23
  } from '@/lib/types';
24
24
 
25
- import { actForDirection } from '@medicine-wheel/narrative-engine';
25
+ import { createBeat as authorBeat, validateBeatDraft, type BeatDraft } from '@medicine-wheel/narrative-engine';
26
26
  import { extractBeats } from './beat-response';
27
27
  import { extractCycles, normalizeMedicineWheelCycle } from './cycle-response';
28
28
  import { getJsonlStore } from './jsonl-store';
@@ -138,32 +138,73 @@ export function getBeatsByDirection(direction: string): NarrativeBeat[] {
138
138
  // `act` is optional because it is derived from `direction`, not supplied.
139
139
  // Requiring it invited callers to pass a constant, which is how a west beat
140
140
  // came to be recorded in act 1.
141
+ /**
142
+ * Create a beat through the authoring door.
143
+ *
144
+ * This delegates to `@medicine-wheel/narrative-engine` rather than assembling
145
+ * the record itself. It did assemble it, and that made the REST surface a
146
+ * second minting site: a beat posted over HTTP was never checked by
147
+ * `validateBeatDraft`, so the door only governed callers who happened to come
148
+ * through MCP. Five beats were registered that way before this was noticed —
149
+ * they were valid, but nothing had confirmed it.
150
+ *
151
+ * Throws on an invalid draft. The route turns that into a 400, which is the
152
+ * honest answer: a beat that fails the wheel's laws should not be stored and
153
+ * then reported as created.
154
+ */
141
155
  export function createBeat(
142
156
  data: Omit<NarrativeBeat, 'id' | 'timestamp' | 'act'> & { id?: string; timestamp?: string; act?: number },
143
- ): NarrativeBeat {
144
- const id = data.id || crypto.randomUUID();
145
- const beat: NarrativeBeat = {
146
- id,
157
+ ): { beat: NarrativeBeat; warnings: string[] } {
158
+ // A POST carrying an existing id is an update. Its provenance was stamped at
159
+ // creation; re-stamping `origin` on every touch would rewrite who authored
160
+ // the beat — the record must keep its first producer unless the caller
161
+ // states a new one deliberately.
162
+ const existing = data.id ? (store.getBeat(data.id) as any) : undefined;
163
+
164
+ const draft: BeatDraft = {
147
165
  direction: data.direction,
148
166
  title: data.title,
149
167
  description: data.description,
150
168
  prose: data.prose,
151
169
  ceremonies: data.ceremonies ?? [],
152
170
  learnings: data.learnings ?? [],
153
- timestamp: data.timestamp || new Date().toISOString(),
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),
158
171
  relations_honored: data.relations_honored ?? [],
172
+ act: data.act,
173
+ id: data.id ?? crypto.randomUUID(),
174
+ timestamp: data.timestamp,
175
+ cycle_id: data.cycle_id,
176
+ parent_beat_id: data.parent_beat_id,
177
+ origin: data.origin ?? existing?.origin ?? { producer: 'rest' },
159
178
  };
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;
179
+
180
+ // `strictAct` turns an act that contradicts the direction into an error
181
+ // instead of a stored contradiction. The act is derived, never chosen; a
182
+ // caller supplying the natural act is fine, a caller supplying any other is
183
+ // told so rather than silently recorded.
184
+ const beat = authorBeat(draft, { strictAct: true });
185
+
186
+ // The door computes warnings on every draft; discarding them made the
187
+ // advisory half of validation invisible on the paths people actually use.
188
+ const warnings = validateBeatDraft(draft)
189
+ .violations.filter(v => v.severity === 'warning')
190
+ .map(v => `${v.field}: ${v.message}`);
191
+
162
192
  if (data.sub_beats !== undefined) beat.sub_beats = data.sub_beats;
163
- if (data.origin !== undefined) beat.origin = data.origin;
164
193
 
165
194
  store.createBeat(beat as any);
166
195
 
196
+ // Reciprocate the child side of any sub_beats named on this beat. A parent
197
+ // listing children that do not point back is the same one-sided relation the
198
+ // cycle and parent bindings below exist to prevent.
199
+ if (beat.sub_beats?.length) {
200
+ for (const childId of beat.sub_beats) {
201
+ const child = store.getBeat(childId) as any;
202
+ if (child && child.parent_beat_id !== beat.id) {
203
+ store.createBeat({ ...child, parent_beat_id: beat.id });
204
+ }
205
+ }
206
+ }
207
+
167
208
  // Bind the cycle side of the relation. A beat naming a cycle that does not
168
209
  // list it back is invisible to every arc reader that starts from the cycle.
169
210
  if (beat.cycle_id) {
@@ -181,7 +222,7 @@ export function createBeat(
181
222
  }
182
223
  }
183
224
 
184
- return beat;
225
+ return { beat, warnings };
185
226
  }
186
227
 
187
228
  // ── Cycles ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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",
@@ -23,35 +23,37 @@
23
23
  "README.md",
24
24
  "ALPHA.md"
25
25
  ],
26
+ "//workspaces": "TOPOLOGICAL ORDER — do not alphabetize. `npm run <script> --workspaces` executes in THIS array's order, not in dependency order: a package listed before a package it imports builds first and fails TS2307 on any tree without a stale dist/. Roots first (ontology-core, creative-orientation, session-reader), then their dependents, mcp last. A new package goes after everything it imports.",
26
27
  "workspaces": [
27
- "src/brainstorming",
28
+ "src/ontology-core",
28
29
  "src/creative-orientation",
29
- "src/gap-analysis",
30
+ "src/session-reader",
30
31
  "src/ceremony-protocol",
31
- "src/community-review",
32
- "src/consent-lifecycle",
32
+ "src/storage-provider",
33
33
  "src/data-store",
34
34
  "src/data-store-postgres",
35
- "src/fire-keeper",
36
35
  "src/graph-viz",
37
36
  "src/importance-unit",
38
- "src/narrative-cluster",
39
37
  "src/narrative-engine",
40
- "src/ontology-core",
38
+ "src/narrative-cluster",
41
39
  "src/perception-layer",
42
40
  "src/prompt-decomposition",
43
41
  "src/relational-index",
44
42
  "src/relational-query",
45
- "src/session-reader",
46
- "src/storage-provider",
43
+ "src/ui-components",
44
+ "src/brainstorming",
45
+ "src/gap-analysis",
46
+ "src/creative-problem-solving",
47
+ "src/community-review",
48
+ "src/consent-lifecycle",
49
+ "src/fire-keeper",
50
+ "src/transformation-tracker",
47
51
  "src/ceremonial-diary",
48
52
  "src/github-ceremony",
49
- "src/transformation-tracker",
50
- "src/ui-components",
51
53
  "mcp"
52
54
  ],
53
55
  "scripts": {
54
- "build:packages": "npm run build -w src/ontology-core && npm run build --workspaces --if-present",
56
+ "build:packages": "npm run build -w src/ontology-core -w src/creative-orientation && npm run build --workspaces --if-present",
55
57
  "build:cli": "tsc -p tsconfig.cli.json && node -e \"for (const f of ['dist/cli/mw.js','dist/cli/mwsrv.js']) { try { require('fs').chmodSync(f, 0o755); } catch (e) { console.warn('[cli] chmod skipped (non-fatal):', e.message); } }\"",
56
58
  "clean:packages": "npm run clean --workspaces --if-present",
57
59
  "prebuild": "npm run build:packages",
@@ -82,31 +84,32 @@
82
84
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
83
85
  },
84
86
  "dependencies": {
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",
87
+ "@medicine-wheel/ceremonial-diary": "^0.5.4",
88
+ "@medicine-wheel/ceremony-protocol": "^0.5.4",
89
+ "@medicine-wheel/community-review": "^0.5.4",
90
+ "@medicine-wheel/consent-lifecycle": "^0.5.4",
91
+ "@medicine-wheel/data-store": "^0.5.4",
92
+ "@medicine-wheel/data-store-postgres": "^0.5.4",
93
+ "@medicine-wheel/fire-keeper": "^0.5.4",
94
+ "@medicine-wheel/github-ceremony": "^0.5.4",
95
+ "@medicine-wheel/graph-viz": "^0.5.4",
96
+ "@medicine-wheel/importance-unit": "^0.5.4",
97
+ "@medicine-wheel/mcp": "^4.5.4",
98
+ "@medicine-wheel/narrative-cluster": "^0.5.4",
99
+ "@medicine-wheel/narrative-engine": "^0.5.4",
100
+ "@medicine-wheel/ontology-core": "^0.5.4",
101
+ "@medicine-wheel/perception-layer": "^0.5.4",
102
+ "@medicine-wheel/prompt-decomposition": "^0.5.4",
103
+ "@medicine-wheel/relational-index": "^0.5.4",
104
+ "@medicine-wheel/relational-query": "^0.5.4",
105
+ "@medicine-wheel/session-reader": "^0.5.4",
106
+ "@medicine-wheel/storage-provider": "^0.5.4",
107
+ "@medicine-wheel/transformation-tracker": "^0.5.4",
108
+ "@medicine-wheel/ui-components": "^0.5.4",
107
109
  "@neondatabase/serverless": "^0.10.0",
108
110
  "@xyflow/react": "^12.3.0",
109
111
  "clsx": "^2.1.1",
112
+ "js-yaml": "^4.3.0",
110
113
  "lucide-react": "^0.475.0",
111
114
  "next": "^15.3.0",
112
115
  "next-themes": "^0.4.6",
@@ -119,6 +122,7 @@
119
122
  },
120
123
  "devDependencies": {
121
124
  "@tailwindcss/postcss": "^4.1.0",
125
+ "@types/js-yaml": "^4.0.9",
122
126
  "@types/node": "^22.0.0",
123
127
  "@types/react": "^19.0.0",
124
128
  "@types/react-dom": "^19.0.0",