@ak--47/dungeon-master 1.5.2 → 1.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.
@@ -134,7 +134,12 @@ FROM (
134
134
 
135
135
  ## Step 3: Write Report
136
136
 
137
- Create `soup-analysis.md` in the project root with:
137
+ Create `soup-analysis.md` with the structure below. For a user dungeon
138
+ (`dungeons/user/<name>/<name>.js`) write it into the dungeon's folder
139
+ (`dungeons/user/<name>/soup-analysis.md`) — everything about a dungeon lives in
140
+ its folder. Otherwise write it to the project root. (The generated
141
+ `./data/soup-analysis-EVENTS.json` is throwaway verification data — leave it in
142
+ `./data/`.) Contents:
138
143
 
139
144
  1. **Config**: The soup parameters used (peaks, deviation, mean, numDays)
140
145
  2. **Summary stats**: Total events, event count, avg EPS
@@ -18,7 +18,7 @@ box. It does **NOT** engineer story trends or magic numbers — those are the
18
18
  should be:
19
19
 
20
20
  ```
21
- /write-hooks dungeons/user/<your-dungeon>.js "describe the trends to engineer"
21
+ /write-hooks dungeons/user/<name>/<name>.js "describe the trends to engineer"
22
22
  ```
23
23
 
24
24
  In scope here:
@@ -63,7 +63,7 @@ Before writing any code, scan:
63
63
  - `lib/utils/utils.js` — `pickAWinner`, `weighNumRange`, `initChance`, `exhaust`,
64
64
  `takeSome` for property value distributions
65
65
  - `dungeons/vertical/sass.js` — B2B reference dungeon with full identity model
66
- - `dungeons/user/my-buddy.js` — consumer-app reference (gitignored)
66
+ - `dungeons/user/my-buddy/my-buddy.js` — consumer-app reference (gitignored)
67
67
  - `dungeons/technical/identity-model-verify.js` — minimal identity-model fixture
68
68
 
69
69
  ## File structure
@@ -524,9 +524,9 @@ userProps: { Plan: PLANS, Region: REGIONS, Role: ROLES, ... },
524
524
 
525
525
  After writing the file:
526
526
 
527
- 1. Smoke-test: `node scripts/verify-runner.mjs dungeons/user/<file>.js verify-<file> --small`. Confirm zero errors.
528
- 2. Hand to the next skill: `/write-hooks dungeons/user/<file>.js "describe trends"`.
529
- 3. After hooks land: `/verify-dungeon dungeons/user/<file>.js`.
527
+ 1. Smoke-test: `node scripts/verify-runner.mjs dungeons/user/<name>/<name>.js verify-<name> --small`. Confirm zero errors.
528
+ 2. Hand to the next skill: `/write-hooks dungeons/user/<name>/<name>.js "describe trends"`.
529
+ 3. After hooks land: `/verify-dungeon dungeons/user/<name>/<name>.js`.
530
530
 
531
531
  ## Property Type Reference
532
532
 
@@ -550,8 +550,20 @@ When designing event properties, always consider which Mixpanel type best repres
550
550
 
551
551
  ## Output
552
552
 
553
- Write the file to `dungeons/user/<descriptive-name>.js`. Do NOT inject hooks.
554
- Do NOT use `subscription`, `attribution`, `geo`, `features`, or `anomalies`
555
- (the engine will silently strip them and warn).
553
+ **One folder per customer/dungeon.** Pick a short, kebab-case `<name>` from the
554
+ app/customer, then **create `dungeons/user/<name>/` if it doesn't already exist**
555
+ (`mkdir -p dungeons/user/<name>`) and write the dungeon to
556
+ `dungeons/user/<name>/<name>.js` (e.g. `dungeons/user/acme/acme.js`). Folder and
557
+ file share the name, matching `kodiak/kodiak.js`, `my-buddy/my-buddy.js`.
558
+
559
+ This keeps `dungeons/user/` organized — EVERYTHING about this dungeon lives in
560
+ the same folder: `hook-results.md` + `hook-query-log.txt` +
561
+ `<name>-verifications.sql` (from `verify-dungeon`), `soup-analysis.md` (from
562
+ `analyze-soup`), briefs, schema CSV/JSON, example data. The only thing kept
563
+ outside is the throwaway verification data the runs write to `./data/` (cleaned
564
+ after).
565
+
566
+ Do NOT inject hooks. Do NOT use `subscription`, `attribution`, `geo`,
567
+ `features`, or `anomalies` (the engine will silently strip them and warn).
556
568
 
557
569
  When done, tell the user the next skill to run.
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: create-project
3
+ description: Use when an existing dungeon needs a real Mixpanel project provisioned before sending data — creates the project, sets timezone UTC, mints a scoped service account, adds the dungeon's group keys, uploads business context (AI context), and writes the resulting credentials back into the dungeon so it "just runs". Follows create-dungeon / write-hooks / verify-dungeon.
4
+ argument-hint: [dungeon path, e.g. dungeons/user/shopstream/shopstream.js]
5
+ model: claude-opus-4-6
6
+ effort: max
7
+ ---
8
+
9
+ # Create a Mixpanel Project for a Dungeon
10
+
11
+ Provision a fresh Mixpanel project for an existing dungeon and wire its credentials back in.
12
+
13
+ **Dungeon file:** `$ARGUMENTS`
14
+
15
+ This is the step after a dungeon is authored, reviewed, and tweaked. It turns a
16
+ local dungeon into one you can actually send to Mixpanel by creating the project
17
+ and stamping `credentials` back into the file.
18
+
19
+ ## What it does
20
+
21
+ All work runs through the orchestrator `provision.mjs` (this skill's directory),
22
+ which calls the [power-tools API](https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app)
23
+ in order:
24
+
25
+ 1. **createProject** — name derived from the dungeon's `OVERVIEW` (`NAME:` line), region `US`, timezone `UTC` (set as a follow-up by the endpoint).
26
+ 2. **mintServiceAccount** — `admin`, expires `+30 days`, scoped to the new project. This is what the dungeon uses to **send** data.
27
+ 3. **addGroupKey** — one per `groupKeys` entry in the dungeon (`property_name` + a titleized `display_name`). Skipped if the dungeon has no group keys.
28
+ 4. **setBusinessContext** — markdown built from the dungeon's `OVERVIEW` + `HOOK STORIES` comment blocks (via the package's `extractComments`) plus an events/funnels/props/group-keys summary, capped at 50k chars.
29
+ 5. **write-back** — replaces the dungeon's `credentials: { … }` block with `{ token, projectId, serviceAccount, serviceSecret, region }`.
30
+
31
+ **Always creates a fresh project.** Re-running mints a new project and overwrites
32
+ `credentials`. Auth for all four calls is the OAuth `BEARER_TOKEN`; the minted
33
+ service account is only written into the dungeon for later data sends.
34
+
35
+ ## Prerequisites
36
+
37
+ `.env` at the repo root must contain (both are gitignored):
38
+
39
+ ```
40
+ BEARER_TOKEN=<oauth token> # creates projects / mints SAs / adds group keys
41
+ ORG_ID=<organization id>
42
+ ```
43
+
44
+ If either is missing the orchestrator exits with a clear message — tell the user
45
+ to add them to `.env`.
46
+
47
+ User dungeons (`dungeons/user/`, `dungeons/customers/`, `dungeons/capstone/`) are
48
+ gitignored, so writing plaintext credentials into them is expected and safe.
49
+
50
+ ## Steps
51
+
52
+ ### 1. Show the plan (dry run)
53
+
54
+ ```bash
55
+ node .claude/skills/create-project/provision.mjs <dungeon-path> --dry-run
56
+ ```
57
+
58
+ This makes **no** API calls. It prints the derived project name, group-key
59
+ mapping, service-account name/expiry, and a business-context preview. Show this
60
+ to the user.
61
+
62
+ ### 2. Confirm
63
+
64
+ Creating a real project + service account is outward-facing and not easily
65
+ undone. Confirm with the user before the live run (one line is enough).
66
+
67
+ ### 3. Provision (live)
68
+
69
+ ```bash
70
+ node .claude/skills/create-project/provision.mjs <dungeon-path>
71
+ ```
72
+
73
+ On success it writes `credentials` back into the dungeon and prints a non-secret
74
+ summary (project URL + id, SA username/expiry, group keys added/skipped, context
75
+ size). Token and secret are written into the dungeon, not printed.
76
+
77
+ ### 4. Report
78
+
79
+ Relay the project URL and confirm credentials were written. Point the user at the
80
+ run command:
81
+
82
+ ```bash
83
+ node scripts/run-dungeon.mjs <dungeon-path>
84
+ ```
85
+
86
+ ## Error handling
87
+
88
+ - **Missing `BEARER_TOKEN` / `ORG_ID`** — orchestrator exits; have the user fix `.env`.
89
+ - **`createProject` fails** — nothing is provisioned; surface the power-tools error (`{ error }` or `{ errors:[{param,message}] }`) verbatim and stop.
90
+ - **A later step fails** (mint / group keys / context / write-back) — the orchestrator continues, writes back whatever succeeded, and lists the failed step under `⚠ warnings`. Relay those warnings; the user may re-run or fix manually.
91
+
92
+ ## Notes
93
+
94
+ - Region is always `US`; timezone is always `UTC` (matches the dungeon's UTC time window so Mixpanel day-bucketing aligns).
95
+ - Group keys come straight from the dungeon's `groupKeys` (`[["parent_account", N, []], …]`) — author them there (via `create-dungeon`) before running this skill.
96
+ - The orchestrator reuses the package's own exports (`loadFromFile`, `extractComments`) — no separate parser to keep in sync.
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * create-project orchestrator.
5
+ *
6
+ * Given an existing dungeon file, provisions a fresh Mixpanel project via the
7
+ * power-tools API and writes the resulting credentials back into the dungeon so
8
+ * it can "just run":
9
+ *
10
+ * 1. createProject (sets timezone UTC as a follow-up)
11
+ * 2. mintServiceAccount (admin, +30d) — scoped to the new project
12
+ * 3. addGroupKey (only if the dungeon declares groupKeys)
13
+ * 4. setBusinessContext (OVERVIEW + HOOK STORIES + schema summary)
14
+ * 5. write `credentials: { token, projectId, serviceAccount, serviceSecret, region }`
15
+ * back into the dungeon (gitignored user dungeons — plaintext is fine)
16
+ *
17
+ * Auth: all calls use the OAuth BEARER_TOKEN from .env. The minted service
18
+ * account is for the dungeon to SEND data later, not for setup.
19
+ *
20
+ * Usage:
21
+ * node .claude/skills/create-project/provision.mjs <dungeon-path> [--dry-run]
22
+ *
23
+ * Env (.env at repo root):
24
+ * BEARER_TOKEN=<oauth token>
25
+ * ORG_ID=<organization id>
26
+ */
27
+
28
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
29
+ import path, { dirname, resolve } from 'path';
30
+ import { fileURLToPath } from 'url';
31
+ import dotenv from 'dotenv';
32
+ import { loadFromFile, extractComments } from '../../../index.js';
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+ const REPO_ROOT = resolve(__dirname, '../../../');
36
+
37
+ dotenv.config({ path: resolve(REPO_ROOT, '.env') });
38
+
39
+ const BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
40
+ const CLIENT_ID = 'dungeon-master';
41
+ const REGION = 'US';
42
+ const SA_TTL_DAYS = 30;
43
+
44
+ // ── args ──────────────────────────────────────────────────────────────────
45
+ const args = process.argv.slice(2);
46
+ const dryRun = args.includes('--dry-run');
47
+ const dungeonArg = args.find((a) => !a.startsWith('--'));
48
+
49
+ if (!dungeonArg) {
50
+ fail('Usage: node .claude/skills/create-project/provision.mjs <dungeon-path> [--dry-run]');
51
+ }
52
+
53
+ const dungeonPath = resolve(process.cwd(), dungeonArg);
54
+ if (!existsSync(dungeonPath)) fail(`dungeon file not found: ${dungeonPath}`);
55
+
56
+ // ── main ──────────────────────────────────────────────────────────────────
57
+ const config = await loadFromFile(dungeonPath);
58
+ const comments = extractComments(dungeonPath);
59
+
60
+ const name = deriveName(comments, dungeonPath);
61
+ const groupKeys = Array.isArray(config.groupKeys)
62
+ ? config.groupKeys.filter(Boolean).map(([prop]) => ({ property_name: prop, display_name: titleize(prop) }))
63
+ : [];
64
+ const saName = `${slug(name)}-dungeon-sa`.slice(0, 64);
65
+ const content = buildContext(name, config, comments, groupKeys);
66
+
67
+ if (dryRun) {
68
+ printPlan();
69
+ process.exit(0);
70
+ }
71
+
72
+ // Live run needs auth.
73
+ const { BEARER_TOKEN, ORG_ID } = process.env;
74
+ if (!BEARER_TOKEN) fail('BEARER_TOKEN missing from .env (OAuth token required to create projects).');
75
+ if (!ORG_ID) fail('ORG_ID missing from .env (organization id required to create projects).');
76
+
77
+ const warnings = [];
78
+
79
+ // 1. create project (timezone set to UTC as a follow-up by the endpoint)
80
+ let project;
81
+ try {
82
+ project = await post('/crud/createProject', { org_id: ORG_ID, name, timezone: 'UTC' });
83
+ } catch (err) {
84
+ fail(`createProject failed — nothing provisioned.\n ${err.message}`);
85
+ }
86
+ const projectId = String(project.id);
87
+ const projectToken = project.token;
88
+
89
+ // 2. mint service account (admin, +30d)
90
+ let sa = null;
91
+ const expires = isoInDays(SA_TTL_DAYS);
92
+ try {
93
+ sa = await post('/crud/mintServiceAccount', {
94
+ org_id: ORG_ID,
95
+ project_id: projectId,
96
+ name: saName,
97
+ role: 'admin',
98
+ expires,
99
+ });
100
+ } catch (err) {
101
+ warnings.push(`mintServiceAccount failed: ${err.message}`);
102
+ }
103
+
104
+ // 3. group keys (only if declared)
105
+ let groupKeyResult = null;
106
+ if (groupKeys.length) {
107
+ try {
108
+ groupKeyResult = await post('/crud/addGroupKey', { project_id: projectId, group_keys: groupKeys });
109
+ } catch (err) {
110
+ warnings.push(`addGroupKey failed: ${err.message}`);
111
+ }
112
+ }
113
+
114
+ // 4. business context
115
+ try {
116
+ await post('/crud/setBusinessContext', { project_id: projectId, content });
117
+ } catch (err) {
118
+ warnings.push(`setBusinessContext failed: ${err.message}`);
119
+ }
120
+
121
+ // 5. write credentials back into the dungeon
122
+ const creds = {
123
+ token: projectToken,
124
+ projectId,
125
+ serviceAccount: sa?.username || '',
126
+ serviceSecret: sa?.secret || '',
127
+ region: REGION,
128
+ };
129
+ let wroteBack = true;
130
+ try {
131
+ writeBackCredentials(dungeonPath, creds);
132
+ } catch (err) {
133
+ wroteBack = false;
134
+ warnings.push(`credentials write-back failed: ${err.message}`);
135
+ }
136
+
137
+ printSummary();
138
+
139
+ // ── helpers ─────────────────────────────────────────────────────────────────
140
+
141
+ async function post(pathname, body) {
142
+ const res = await fetch(BASE + pathname, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.BEARER_TOKEN}` },
145
+ body: JSON.stringify({ client_id: CLIENT_ID, region: REGION, ...body }),
146
+ });
147
+ const text = await res.text();
148
+ let json;
149
+ try {
150
+ json = JSON.parse(text);
151
+ } catch {
152
+ json = { raw: text };
153
+ }
154
+ if (!res.ok) {
155
+ const detail = Array.isArray(json.errors)
156
+ ? json.errors.map((e) => `${e.param}: ${e.message}`).join('; ')
157
+ : json.error || text || `HTTP ${res.status}`;
158
+ throw new Error(`${res.status} ${detail}`);
159
+ }
160
+ return json;
161
+ }
162
+
163
+ function deriveName(comments, p) {
164
+ const m = (comments.overview || '').match(/^NAME:\s*(.+)$/m);
165
+ if (m) return m[1].trim();
166
+ return path.basename(p).replace(/\.(js|mjs|json)$/i, '');
167
+ }
168
+
169
+ function titleize(s) {
170
+ return String(s)
171
+ .split(/[_\s-]+/)
172
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
173
+ .join(' ');
174
+ }
175
+
176
+ function slug(s) {
177
+ return String(s)
178
+ .toLowerCase()
179
+ .replace(/[^a-z0-9]+/g, '-')
180
+ .replace(/^-+|-+$/g, '');
181
+ }
182
+
183
+ function isoInDays(days) {
184
+ const ms = Date.now() + days * 24 * 60 * 60 * 1000;
185
+ return new Date(ms).toISOString();
186
+ }
187
+
188
+ function buildContext(name, config, comments, groupKeys) {
189
+ const parts = [`# ${name}`, ''];
190
+ if (comments.overview) parts.push(comments.overview, '');
191
+ if (comments.hookStories) parts.push('## Engineered Behaviors', '', comments.hookStories, '');
192
+
193
+ parts.push('## Schema', '');
194
+ const events = config.events || [];
195
+ parts.push(`### Events (${events.length})`);
196
+ for (const e of events) {
197
+ const props = e.properties ? Object.keys(e.properties).join(', ') : '';
198
+ const weight = e.weight != null ? ` (weight ${e.weight})` : '';
199
+ parts.push(`- ${e.event}${weight}${props ? ` — ${props}` : ''}`);
200
+ }
201
+ parts.push('');
202
+
203
+ const funnels = config.funnels || [];
204
+ if (funnels.length) {
205
+ parts.push(`### Funnels (${funnels.length})`);
206
+ for (const f of funnels) {
207
+ const seq = (f.sequence || []).join(' → ');
208
+ const rate = f.conversionRate != null ? ` (${f.conversionRate}%)` : '';
209
+ parts.push(`- ${f.name || '(unnamed)'}: ${seq}${rate}`);
210
+ }
211
+ parts.push('');
212
+ }
213
+
214
+ if (groupKeys.length) {
215
+ parts.push('### Group keys', ...groupKeys.map((g) => `- ${g.property_name} (${g.display_name})`), '');
216
+ }
217
+
218
+ let md = parts.join('\n');
219
+ if (md.length > 50000) md = md.slice(0, 49900) + '\n\n…(truncated to 50,000 chars)';
220
+ return md;
221
+ }
222
+
223
+ function writeBackCredentials(p, creds) {
224
+ let src = readFileSync(p, 'utf-8');
225
+ const block =
226
+ `credentials: { token: ${q(creds.token)}, projectId: ${q(creds.projectId)}, ` +
227
+ `serviceAccount: ${q(creds.serviceAccount)}, serviceSecret: ${q(creds.serviceSecret)}, region: ${q(creds.region)} }`;
228
+
229
+ const existing = /credentials\s*:\s*\{[\s\S]*?\}/;
230
+ if (existing.test(src)) {
231
+ src = src.replace(existing, block);
232
+ } else {
233
+ const opener = /(const\s+\w+\s*=\s*\{)/;
234
+ if (opener.test(src)) {
235
+ src = src.replace(opener, `$1\n\t${block},`);
236
+ } else {
237
+ throw new Error('no `credentials` block or `const X = {` opener found — add a credentials block manually.');
238
+ }
239
+ }
240
+ writeFileSync(p, src, 'utf-8');
241
+ }
242
+
243
+ function q(v) {
244
+ return JSON.stringify(String(v ?? ''));
245
+ }
246
+
247
+ function printPlan() {
248
+ const preview = content.length > 600 ? content.slice(0, 600) + ' …' : content;
249
+ console.log('── create-project plan (dry run) ────────────────────────────');
250
+ console.log(`dungeon: ${path.relative(process.cwd(), dungeonPath)}`);
251
+ console.log(`project name: ${name}`);
252
+ console.log(`region: ${REGION} timezone: UTC`);
253
+ console.log(`service acct: ${saName} (role admin, expires +${SA_TTL_DAYS}d)`);
254
+ console.log(`group keys: ${groupKeys.length ? groupKeys.map((g) => `${g.property_name} → "${g.display_name}"`).join(', ') : '(none)'}`);
255
+ console.log(`business ctx: ${content.length} chars`);
256
+ console.log('');
257
+ console.log('would POST: createProject → mintServiceAccount' + (groupKeys.length ? ' → addGroupKey' : '') + ' → setBusinessContext');
258
+ console.log('then write credentials back into the dungeon.');
259
+ console.log('');
260
+ console.log('── business context preview ─────────────────────────────────');
261
+ console.log(preview);
262
+ }
263
+
264
+ function printSummary() {
265
+ console.log('── create-project: provisioned ──────────────────────────────');
266
+ console.log(`project: ${name} (id ${projectId})`);
267
+ if (project.url) console.log(`url: ${project.url}`);
268
+ console.log(`region: ${REGION} timezone: UTC`);
269
+ console.log(`service acct: ${sa ? `${sa.username} (role ${sa.role}, expires ${sa.expires})` : '(FAILED — see warnings)'}`);
270
+ if (groupKeys.length) {
271
+ const added = groupKeyResult?.added?.map((g) => g.property_name).join(', ') || '(none)';
272
+ const skipped = groupKeyResult?.skipped?.join(', ') || '(none)';
273
+ console.log(`group keys: added [${added}] skipped [${skipped}]`);
274
+ }
275
+ console.log(`business ctx: ${content.length} chars uploaded`);
276
+ console.log(`credentials: ${wroteBack ? 'written back into dungeon ✓' : 'NOT written (see warnings)'}`);
277
+ if (warnings.length) {
278
+ console.log('');
279
+ console.log('⚠ warnings:');
280
+ for (const w of warnings) console.log(` - ${w}`);
281
+ }
282
+ console.log('');
283
+ console.log(`next: node scripts/run-dungeon.mjs ${path.relative(process.cwd(), dungeonPath)}`);
284
+ }
285
+
286
+ function fail(msg) {
287
+ console.error(`✖ ${msg}`);
288
+ process.exit(1);
289
+ }
@@ -102,13 +102,33 @@ For emulator details, identity-model dungeons (must pass `profiles`), and time-s
102
102
  - Experiment invariants (variant distribution, exposure timing, deterministic assignment) when any funnel uses `experiment:`
103
103
  - SuperProp consistency, SuperProp/UserProp mirror, Mixpanel default-property casing, funnel-pre dilution
104
104
 
105
- ### Step 5: Stash query log (optional)
105
+ ### Artifact location
106
106
 
107
- If `./research/` exists locally, write every DuckDB query execution to `./research/hook-query-log.txt`. Format and conventions: see [report-format.md "Query log format"](references/report-format.md#query-log-format). If `./research/` does not exist, skip — do not create the directory.
107
+ **Everything about a dungeon lives in its folder.** When the dungeon being
108
+ verified is a user dungeon at `dungeons/user/<name>/<name>.js`, write ALL
109
+ generated artifacts into `dungeons/user/<name>/`:
110
+ - `hook-results.md` (Step 6)
111
+ - `hook-query-log.txt` (Step 5)
112
+ - `<name>-verifications.sql` (Step 6b)
108
113
 
109
- ### Step 6: Write `./research/hook-results.md`
114
+ The ONLY exception is the throwaway verification data the run writes to
115
+ `./data/` (`verify-*` event/user files) — that stays in `./data/` and is
116
+ deleted in Step 7.
110
117
 
111
- Use the templates in [report-format.md](references/report-format.md):
118
+ For non-user dungeons (technical/vertical) or batch runs across many dungeons,
119
+ fall back to `./research/` for `hook-results.md` / `hook-query-log.txt`.
120
+
121
+ ### Step 5: Stash query log
122
+
123
+ Write every DuckDB query execution to `hook-query-log.txt`:
124
+ - **User dungeon:** always write to `dungeons/user/<name>/hook-query-log.txt`.
125
+ - **Otherwise:** if `./research/` exists locally, write to `./research/hook-query-log.txt`; if it doesn't exist, skip — do not create the directory.
126
+
127
+ Format and conventions: see [report-format.md "Query log format"](references/report-format.md#query-log-format).
128
+
129
+ ### Step 6: Write `hook-results.md`
130
+
131
+ Write to `dungeons/user/<name>/hook-results.md` for a user dungeon, else `./research/hook-results.md`. Use the templates in [report-format.md](references/report-format.md):
112
132
  - Single-dungeon report structure
113
133
  - Multi-dungeon report structure (when batch mode)
114
134
  - Per-hook detail block
@@ -118,7 +138,7 @@ Use the templates in [report-format.md](references/report-format.md):
118
138
 
119
139
  ### Step 6b: Write verification SQL (mandatory for user dungeons)
120
140
 
121
- When verifying a dungeon in `dungeons/user/`, also write a standalone DuckDB SQL file at `dungeons/user/<name>-verifications.sql`. Vertical dungeons already have their SQL in `verification/verticals/`. Format: see [report-format.md "Verification SQL file"](references/report-format.md#verification-sql-file-mandatory-for-user-dungeons).
141
+ When verifying a dungeon in `dungeons/user/`, also write a standalone DuckDB SQL file alongside the dungeon in its folder at `dungeons/user/<name>/<name>-verifications.sql`. Vertical dungeons already have their SQL in `verification/verticals/`. Format: see [report-format.md "Verification SQL file"](references/report-format.md#verification-sql-file-mandatory-for-user-dungeons).
122
142
 
123
143
  ### Step 7: Cleanup
124
144
 
@@ -148,9 +168,9 @@ Return-value behavior:
148
168
  ## Final output
149
169
 
150
170
  Tell the user:
151
- 1. Report path: `./research/hook-results.md`
152
- 2. Verification SQL path (for user dungeons): `dungeons/user/<name>-verifications.sql`
153
- 3. Query log path (if written): `./research/hook-query-log.txt`
171
+ 1. Report path: `dungeons/user/<name>/hook-results.md` (user dungeon) or `./research/hook-results.md`
172
+ 2. Verification SQL path (for user dungeons): `dungeons/user/<name>/<name>-verifications.sql`
173
+ 3. Query log path (if written): alongside the report (`dungeons/user/<name>/hook-query-log.txt`, else `./research/hook-query-log.txt`)
154
174
  4. Pass/weak/fail counts (per dungeon if batch mode)
155
175
  5. One-line summary of the most interesting finding
156
176
 
@@ -1,6 +1,6 @@
1
1
  # Report Format
2
2
 
3
- Templates and conventions for writing `./research/hook-results.md` and per-dungeon verification SQL.
3
+ Templates and conventions for writing `hook-results.md` and per-dungeon verification SQL. For user dungeons these live in the dungeon's folder (`dungeons/user/<name>/`); otherwise in `./research/`. See [SKILL.md "Artifact location"](../SKILL.md).
4
4
 
5
5
  ## Verdict criteria (5-tier)
6
6
 
@@ -147,9 +147,7 @@ Each hook's detailed section follows this template (same for single and multi-du
147
147
 
148
148
  ## Query log format
149
149
 
150
- If `./research/` exists locally, write a plain-text log of every DuckDB query execution to `./research/hook-query-log.txt`. If `./research/` does not exist, skip this step entirely do not create the directory.
151
-
152
- Check with: `ls -d ./research/ 2>/dev/null`
150
+ Write a plain-text log of every DuckDB query execution to `hook-query-log.txt`. For a user dungeon, write it to the dungeon's folder (`dungeons/user/<name>/hook-query-log.txt`). Otherwise, only if `./research/` exists locally write `./research/hook-query-log.txt` — if it doesn't exist, skip this step entirely (don't create the directory; check with `ls -d ./research/ 2>/dev/null`).
153
151
 
154
152
  Use a consistent delimited format — one block per query, separated by a ruler line. DuckDB table output is preserved verbatim:
155
153
 
@@ -191,7 +189,7 @@ grep "^DUNGEON:" research/hook-query-log.txt # list of dungeons querie
191
189
 
192
190
  ## Verification SQL file (mandatory for user dungeons)
193
191
 
194
- When verifying a dungeon in `dungeons/user/`, write a standalone DuckDB SQL file alongside the dungeon at `dungeons/user/<name>-verifications.sql`. This file is the reproducible verification artifact — anyone can re-run it against fresh data.
192
+ When verifying a dungeon in `dungeons/user/`, write a standalone DuckDB SQL file alongside the dungeon in its folder at `dungeons/user/<name>/<name>-verifications.sql`. This file is the reproducible verification artifact — anyone can re-run it against fresh data.
195
193
 
196
194
  Follow the format in `verification/verticals/`:
197
195
 
@@ -200,8 +198,8 @@ Follow the format in `verification/verticals/`:
200
198
  -- <name>.js — Hook Verification SQL (N hooks)
201
199
  -- ============================================================================
202
200
  -- USAGE:
203
- -- 1. node scripts/verify-runner.mjs dungeons/user/<name>.js verify-<name>
204
- -- 2. duckdb < dungeons/user/<name>-verifications.sql
201
+ -- 1. node scripts/verify-runner.mjs dungeons/user/<name>/<name>.js verify-<name>
202
+ -- 2. duckdb < dungeons/user/<name>/<name>-verifications.sql
205
203
  -- 3. rm -f verify-<name>-*
206
204
  -- ============================================================================
207
205
 
@@ -36,8 +36,8 @@ Out of scope:
36
36
  - `lib/hook-patterns/index.js` — high-level recipes (one per Mixpanel
37
37
  analysis type).
38
38
  - `lib/verify/emulate-breakdown.js` — what `verify-dungeon` will check.
39
- - `dungeons/user/my-buddy.js` — reference dungeon using a mix of atoms and
40
- hand-rolled logic.
39
+ - `dungeons/user/my-buddy/my-buddy.js` — reference dungeon using a mix of atoms
40
+ and hand-rolled logic.
41
41
  - `dungeons/technical/pattern-*.js` — five minimal pattern fixtures, one per
42
42
  recipe.
43
43
  - `HOOKS.md` — encyclopedia of hook recipes organized by story pattern. Contains
package/CHANGELOG.md CHANGED
@@ -2,6 +2,85 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.5.4 — 2026-06-04
6
+
7
+ Patch. Import-phase progress now reaches `onProgress` consumers.
8
+
9
+ ### Changed
10
+
11
+ - **Bumped `mixpanel-import` to `^3.3.2`.** It now fires `progressCallback`
12
+ independently of `verbose` / `showProgress`. Previously the import callbacks
13
+ wired up in `mixpanel-sender.js` never fired in non-verbose runs because the
14
+ importer only invoked them when its stdout progress bar was enabled.
15
+
16
+ ### Fixed
17
+
18
+ - **Import progress reaches `onProgress`** (requires `mixpanel-import >= 3.3.2`).
19
+ Every import call (events, user profiles, ad spend, group profiles, group
20
+ events, SCD) already passed a `progressCallback`; with the dependency bump
21
+ those now emit `{ phase: "import", recordType, processed, total, eps,
22
+ bytesProcessed }` to the consumer's `onProgress` during the import phase.
23
+ `showProgress: !!verbose` is unchanged — it still gates only the importer's
24
+ stdout bar, so non-verbose runs stay quiet while the callback fires.
25
+
26
+ ### Why
27
+
28
+ Consumers (e.g. DM4) already handle `update.phase === 'import'` to render an
29
+ import progress bar; the callbacks simply weren't firing. This is a dependency
30
+ bump plus a regression test — no DM API change. Consumers pick it up via their
31
+ normal upgrade flow with no code change.
32
+
33
+ ## 1.5.3 — 2026-06-04
34
+
35
+ Adds two JSON/source interop helpers to the public API. No breaking changes —
36
+ existing exports and behavior are untouched.
37
+
38
+ ### Added
39
+
40
+ - **`dungeonToJSON(input, options?)`** export. The inverse of `parseJSONDungeon`:
41
+ turns a dungeon into the `{ schema, hooks, timestamp, version }` JSON/UI wrapper
42
+ format. Accepts the same input flavors as the default export — a config object,
43
+ a `.js`/`.mjs`/`.json` file path, a raw JS source string, or an array of file
44
+ paths (returns an array). Output round-trips: `parseJSONDungeon(await
45
+ dungeonToJSON(x))` yields a runnable config. Best effort — arrow functions and
46
+ bound `chance.*` methods survive the round trip; detected utility calls
47
+ (`weighArray`, `weighNumRange`, …) are serialized by name without their
48
+ arguments and revive to `null` (handled gracefully by the validator). To keep
49
+ the field's **type** even when the generator can't be revived, every function
50
+ is sampled at serialization time (closures are still live) and its inferred
51
+ output type is recorded as `dataType` on the serialized object (e.g.
52
+ `{ functionName: "weighNumRange", args: [], dataType: "number" }`).
53
+ **Credentials (`token`, `serviceAccount`, `serviceSecret`, `projectId`,
54
+ `secret`) are stripped by default** so tokens never leak into JSON — pass
55
+ `{ includeCredentials: true }` to keep them.
56
+ - **`DungeonJSON`, `DungeonComments`, and `SerializedFunction` types** in
57
+ `types.d.ts` — the JSON-representation shapes are now formally specced.
58
+ - **`extractComments(input)`** export. Pulls the human-readable doc blocks out of
59
+ a dungeon's **source** — the `// ── OVERVIEW ──` and `// ── HOOK STORIES ──`
60
+ blocks plus every other `// ── LABEL ──` header that is immediately followed by
61
+ a block comment. Returns `{ overview, hookStories, sections }` with the comment
62
+ scaffolding (`// ──`, `/* */`, leading ` * `) stripped to readable prose.
63
+ Operates on a file path or raw source string — it never imports the dungeon,
64
+ since importing discards comments. Best effort: relies on the canonical
65
+ header + block-comment convention emitted by the `create-dungeon` /
66
+ `write-hooks` skills.
67
+
68
+ ### Changed
69
+
70
+ - **`scripts/dungeon-to-json.mjs`** is now a thin CLI wrapper over the exported
71
+ `dungeonToJSON` (passing `includeCredentials: true` to preserve its legacy
72
+ full-config UI round-trip output). The inline `convertToJSON` /
73
+ `convertFunctionToObject` logic moved into `lib/core/dungeon-to-json.js`.
74
+
75
+ ### Why
76
+
77
+ The package could ingest JSON dungeons (`parseJSONDungeon`, `loadFromFile`,
78
+ `loadFromText`) but had no exported way to go the other direction, and no way to
79
+ programmatically read a dungeon's OVERVIEW / HOOK STORIES documentation. Both
80
+ existed only as un-importable script internals. Exporting them completes
81
+ best-effort JSON interop and lets tools (UIs, LLM pipelines) read dungeon docs
82
+ directly.
83
+
5
84
  ## 1.5.2 — 2026-05-21
6
85
 
7
86
  Docs-only patch. Aligns the `.claude/skills/` authoring + verification
package/README.md CHANGED
@@ -155,6 +155,32 @@ import { createTextGenerator, generateBatch } from '@ak--47/dungeon-master/text'
155
155
 
156
156
  these are the same functions used internally. `pickAWinner` creates weighted distributions, `weighNumRange` generates realistic numeric ranges with configurable skew, and the text generators produce organic-looking strings with sentiment analysis and keyword injection.
157
157
 
158
+ ### named exports
159
+
160
+ alongside the default `DUNGEON_MASTER` export, the package root exports loader + interop helpers:
161
+
162
+ ```javascript
163
+ import DUNGEON_MASTER, {
164
+ loadFromFile, // (path) → Promise<Dungeon> load+validate a .js/.mjs/.json dungeon
165
+ loadFromText, // (code) → Promise<Dungeon> load+validate a raw JS source string
166
+ parseJSONDungeon, // (json) → Dungeon revive a JSON dungeon into a runnable config
167
+ validateDungeonShape, // (config) → void throw if config isn't dungeon-shaped
168
+ dungeonToJSON, // (input, options?) → Promise<DungeonJSON> serialize a dungeon → JSON (inverse of parseJSONDungeon)
169
+ extractComments, // (input) → DungeonComments pull OVERVIEW / HOOK STORIES doc blocks from source
170
+ } from '@ak--47/dungeon-master';
171
+ ```
172
+
173
+ `dungeonToJSON` accepts a config object, a file path, raw JS source, or an array of paths, and returns the `{ schema, hooks, timestamp, version }` wrapper format. it round-trips with `parseJSONDungeon`:
174
+
175
+ ```javascript
176
+ const json = await dungeonToJSON('./dungeons/vertical/ecommerce.js'); // creds stripped by default
177
+ const config = parseJSONDungeon(json); // back to a runnable dungeon
178
+ ```
179
+
180
+ it's best effort — arrow functions and `chance.*` methods survive the round trip; detected utility calls (`weighArray`, `weighNumRange`, …) serialize by name without their args. pass `{ includeCredentials: true }` to keep `token` / `serviceAccount` / etc. in the output (stripped by default).
181
+
182
+ `extractComments` reads a dungeon's **source** (file path or raw text — never the imported module, since importing discards comments) and returns `{ overview, hookStories, sections }` with the comment scaffolding stripped to readable prose.
183
+
158
184
  ## how it works
159
185
 
160
186
  one call to `DUNGEON_MASTER(config)` runs through these phases in order:
package/index.js CHANGED
@@ -15,6 +15,8 @@ import { createContext, updateContextWithStorage } from './lib/core/context.js';
15
15
  import { validateDungeonConfig } from './lib/core/config-validator.js';
16
16
  import { StorageManager } from './lib/core/storage.js';
17
17
  import { detectInputType, loadFromFile, loadFromText, parseJSONDungeon, validateDungeonShape } from './lib/core/dungeon-loader.js';
18
+ import { dungeonToJSON } from './lib/core/dungeon-to-json.js';
19
+ import { extractComments } from './lib/core/extract-comments.js';
18
20
 
19
21
  // Orchestrators
20
22
  import { userLoop } from './lib/orchestrators/user-loop.js';
@@ -597,5 +599,5 @@ function extractStorageData(storage) {
597
599
 
598
600
  // ES Module exports
599
601
  export default DUNGEON_MASTER;
600
- export { parseJSONDungeon, validateDungeonShape, loadFromFile, loadFromText };
602
+ export { parseJSONDungeon, validateDungeonShape, loadFromFile, loadFromText, dungeonToJSON, extractComments };
601
603
 
@@ -0,0 +1,220 @@
1
+ /**
2
+ * dungeon → JSON serialization.
3
+ *
4
+ * The inverse of `parseJSONDungeon` (lib/core/dungeon-loader.js): turns a runnable
5
+ * dungeon into the UI/JSON wrapper format `{ schema, hooks, timestamp, version }`.
6
+ * Functions in the schema are serialized to `{ functionName, body }` / `{ functionName, args }`
7
+ * objects that `reviveJSONConfig` knows how to revive — so the output round-trips:
8
+ *
9
+ * parseJSONDungeon(await dungeonToJSON(cfg)) → runnable config
10
+ *
11
+ * This is BEST EFFORT. Arrow functions and bound `chance.*` methods round-trip cleanly;
12
+ * detected utility calls (weighArray, weighNumRange, …) are serialized without their
13
+ * arguments and revive to null, which the config validator handles gracefully.
14
+ *
15
+ * To preserve the field's TYPE even when the generator can't be revived, every function
16
+ * is sampled at serialization time (when its closure is still live) and the inferred
17
+ * output type is recorded as `dataType` (e.g. "number", "string", "boolean", "number[]").
18
+ * So a field that loses its `weighNumRange(1,10)` generator still records `dataType: "number"`.
19
+ */
20
+
21
+ import { detectInputType, loadFromFile, loadFromText } from './dungeon-loader.js';
22
+
23
+ /** Credential keys stripped from JSON output unless `includeCredentials` is set. */
24
+ const CREDENTIAL_KEYS = ['token', 'serviceAccount', 'serviceSecret', 'projectId', 'secret'];
25
+
26
+ /**
27
+ * Convert a dungeon into its JSON representation.
28
+ *
29
+ * Accepts the same input flavors as the default export: a config object, a path to a
30
+ * `.js`/`.mjs`/`.json` dungeon file, a raw JS source string (must `export default`), or
31
+ * an array of file paths (returns an array of results).
32
+ *
33
+ * @param {import('../../types').Dungeon | string | string[]} input
34
+ * @param {{ includeCredentials?: boolean }} [options]
35
+ * @returns {Promise<import('../../types').DungeonJSON | import('../../types').DungeonJSON[]>}
36
+ */
37
+ export async function dungeonToJSON(input, options = {}) {
38
+ const { includeCredentials = false } = options;
39
+ const { type, value } = detectInputType(input);
40
+
41
+ switch (type) {
42
+ case 'object':
43
+ return serializeConfig(value, includeCredentials);
44
+ case 'file':
45
+ return serializeConfig(await loadFromFile(value), includeCredentials);
46
+ case 'text':
47
+ return serializeConfig(await loadFromText(value), includeCredentials);
48
+ case 'files':
49
+ return Promise.all(
50
+ value.map(async (p) => serializeConfig(await loadFromFile(p), includeCredentials))
51
+ );
52
+ default:
53
+ throw new Error(`dungeon-master: dungeonToJSON cannot handle input type "${type}".`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Build the `{ schema, hooks, timestamp, version }` wrapper from a runnable config.
59
+ * @param {import('../../types').Dungeon} config
60
+ * @param {boolean} includeCredentials
61
+ * @returns {import('../../types').DungeonJSON}
62
+ */
63
+ function serializeConfig(config, includeCredentials) {
64
+ // Hook may be a live function (from .js/text/object) or already a string (from .json).
65
+ const hook = config.hook;
66
+ const hooks = typeof hook === 'function'
67
+ ? hook.toString()
68
+ : (typeof hook === 'string' ? hook : null);
69
+
70
+ // Strip the hook (serialized separately) and, by default, credentials (don't leak tokens).
71
+ const cleanConfig = { ...config };
72
+ delete cleanConfig.hook;
73
+ if (!includeCredentials) {
74
+ for (const key of CREDENTIAL_KEYS) delete cleanConfig[key];
75
+ }
76
+
77
+ return {
78
+ schema: convertToJSON(cleanConfig),
79
+ hooks,
80
+ timestamp: new Date().toISOString(),
81
+ version: '4.0'
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Convert a JavaScript value to a JSON-serializable form, turning functions into
87
+ * `{ functionName, body | args }` objects that `reviveJSONConfig` can revive.
88
+ * @param {any} value
89
+ * @returns {any}
90
+ */
91
+ export function convertToJSON(value) {
92
+ // Null/undefined
93
+ if (value === null || value === undefined) {
94
+ return null;
95
+ }
96
+
97
+ // Primitives
98
+ if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
99
+ return value;
100
+ }
101
+
102
+ // Functions - convert to object representation
103
+ if (typeof value === 'function') {
104
+ return convertFunctionToObject(value);
105
+ }
106
+
107
+ // Arrays
108
+ if (Array.isArray(value)) {
109
+ return value.map(item => convertToJSON(item));
110
+ }
111
+
112
+ // Objects
113
+ if (typeof value === 'object') {
114
+ const result = {};
115
+ for (const [key, val] of Object.entries(value)) {
116
+ result[key] = convertToJSON(val);
117
+ }
118
+ return result;
119
+ }
120
+
121
+ // Fallback
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Convert a function to its object representation.
127
+ * Arrow functions and bound `chance.*` methods round-trip cleanly; detected utility
128
+ * functions are stored by name without args (best effort). Every form also carries a
129
+ * sampled `dataType` so the field's output type survives even when the generator can't.
130
+ * @param {Function} func
131
+ * @returns {import('../../types').SerializedFunction}
132
+ */
133
+ function convertFunctionToObject(func) {
134
+ const funcString = func.toString();
135
+ // Sample now, while the closure is still live — the only reliable time to learn the type.
136
+ const dataType = inferDataType(func);
137
+
138
+ // Arrow function
139
+ if (funcString.startsWith('(') || funcString.startsWith('_') || funcString.includes('=>')) {
140
+ return withDataType({ functionName: 'arrow', body: funcString }, dataType);
141
+ }
142
+
143
+ // Bound chance methods (e.g., chance.name.bind(chance))
144
+ if (funcString.includes('.bind(')) {
145
+ const match = funcString.match(/chance\.(\w+)\.bind/);
146
+ if (match) {
147
+ return withDataType({ functionName: `chance.${match[1]}`, args: [] }, dataType);
148
+ }
149
+ }
150
+
151
+ // Try to detect common utility functions
152
+ // This is a best-effort approach - some complex functions might not be detected
153
+ const commonFunctions = [
154
+ 'weighNumRange',
155
+ 'weighArray',
156
+ 'weighChoices',
157
+ 'pickAWinner',
158
+ 'date',
159
+ 'integer',
160
+ 'uid',
161
+ 'comma'
162
+ ];
163
+
164
+ for (const fnName of commonFunctions) {
165
+ if (funcString.includes(fnName)) {
166
+ // Args can't be recovered from a stringified function, but dataType is captured.
167
+ return withDataType({ functionName: fnName, args: [] }, dataType);
168
+ }
169
+ }
170
+
171
+ // Generic function - just store as arrow function
172
+ return withDataType({ functionName: 'arrow', body: funcString }, dataType);
173
+ }
174
+
175
+ /**
176
+ * Attach a `dataType` field if one was inferred (omitted otherwise).
177
+ * @param {import('../../types').SerializedFunction} obj
178
+ * @param {string | undefined} dataType
179
+ * @returns {import('../../types').SerializedFunction}
180
+ */
181
+ function withDataType(obj, dataType) {
182
+ if (dataType) obj.dataType = dataType;
183
+ return obj;
184
+ }
185
+
186
+ /**
187
+ * Sample a function (no args) and classify its return type. Best effort — returns
188
+ * undefined if the call throws or yields an indeterminate value.
189
+ * @param {Function} func
190
+ * @returns {string | undefined}
191
+ */
192
+ function inferDataType(func) {
193
+ let value;
194
+ try {
195
+ value = func();
196
+ } catch {
197
+ return undefined;
198
+ }
199
+ return classifyValue(value);
200
+ }
201
+
202
+ /**
203
+ * Map a sampled value to a type label: "number" | "string" | "boolean" | "date" |
204
+ * "object" | "<elementType>[]" | "array". Returns undefined for null/undefined/functions.
205
+ * @param {any} value
206
+ * @returns {string | undefined}
207
+ */
208
+ function classifyValue(value) {
209
+ if (value === null || value === undefined) return undefined;
210
+ if (Array.isArray(value)) {
211
+ const el = value.find((v) => v !== null && v !== undefined);
212
+ const elType = el === undefined ? undefined : classifyValue(el);
213
+ return elType ? `${elType}[]` : 'array';
214
+ }
215
+ if (value instanceof Date) return 'date';
216
+ const t = typeof value;
217
+ if (t === 'number' || t === 'string' || t === 'boolean') return t;
218
+ if (t === 'object') return 'object';
219
+ return undefined;
220
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Best-effort extraction of the human-readable doc blocks from a dungeon's SOURCE.
3
+ *
4
+ * Generated/authored dungeons separate sections with box-drawing headers followed by a
5
+ * block comment, e.g.:
6
+ *
7
+ * // ── OVERVIEW ──
8
+ * /* ... *\/
9
+ * // ── HOOK STORIES ──
10
+ * /* ... *\/
11
+ *
12
+ * This pulls those blocks out as cleaned prose. It operates on RAW SOURCE TEXT — it never
13
+ * imports the dungeon, because importing a module discards its comments.
14
+ *
15
+ * Returns `{ overview, hookStories, sections }`:
16
+ * - `overview` — cleaned text of the OVERVIEW block (or null)
17
+ * - `hookStories` — cleaned text of the HOOK STORIES block (or null)
18
+ * - `sections` — every `// ── LABEL ──` header that is immediately followed by a
19
+ * block comment, keyed by the exact label as written.
20
+ */
21
+
22
+ import { readFileSync } from 'fs';
23
+ import { detectInputType } from './dungeon-loader.js';
24
+
25
+ // A `// ── LABEL ──` header line. Anchored to `//` at line start so that the inner
26
+ // ` * ───────` dividers inside a block comment are NOT mistaken for section headers.
27
+ const HEADER_RE = /^[ \t]*\/\/[ \t]*─+[ \t]*(.+?)[ \t]*─+[ \t]*$/gm;
28
+
29
+ /**
30
+ * @param {string | string[]} input - A dungeon file path, raw dungeon source, or array of paths.
31
+ * @returns {import('../../types').DungeonComments | import('../../types').DungeonComments[]}
32
+ */
33
+ export function extractComments(input) {
34
+ const { type, value } = detectInputType(input);
35
+
36
+ switch (type) {
37
+ case 'file':
38
+ return parseSource(readFileSync(value, 'utf-8'));
39
+ case 'text':
40
+ return parseSource(value);
41
+ case 'files':
42
+ return value.map((p) => parseSource(readFileSync(p, 'utf-8')));
43
+ case 'object':
44
+ throw new Error(
45
+ 'dungeon-master: extractComments needs source text or a file path, not a config object (comments are lost once a dungeon is imported).'
46
+ );
47
+ default:
48
+ throw new Error(`dungeon-master: extractComments cannot handle input type "${type}".`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Parse a single source string into `{ overview, hookStories, sections }`.
54
+ * @param {string} source
55
+ * @returns {import('../../types').DungeonComments}
56
+ */
57
+ function parseSource(source) {
58
+ /** @type {Record<string, string>} */
59
+ const sections = {};
60
+
61
+ // Collect every header with its label and the position right after its line.
62
+ const headers = [];
63
+ HEADER_RE.lastIndex = 0;
64
+ let m;
65
+ while ((m = HEADER_RE.exec(source)) !== null) {
66
+ headers.push({ label: m[1].trim(), end: HEADER_RE.lastIndex });
67
+ }
68
+
69
+ for (let i = 0; i < headers.length; i++) {
70
+ const { label, end } = headers[i];
71
+ // Only look between this header and the next one.
72
+ const sliceEnd = i + 1 < headers.length ? headers[i + 1].end : source.length;
73
+ const slice = source.slice(end, sliceEnd);
74
+
75
+ // Accept the block only if a `/* ... */` comment is the first non-whitespace content
76
+ // after the header (so headers followed by code — IMPORTS, SCALE — are skipped).
77
+ const block = slice.match(/^\s*\/\*([\s\S]*?)\*\//);
78
+ if (block) {
79
+ sections[label] = cleanBlock(block[1]);
80
+ }
81
+ }
82
+
83
+ return {
84
+ overview: findSection(sections, ['OVERVIEW']),
85
+ hookStories: findSection(sections, ['HOOK STORIES', 'HOOK STORY']),
86
+ sections
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Strip block-comment scaffolding (` * `) and trim blank edges, leaving readable prose.
92
+ * @param {string} inner - The text between `/*` and `*\/`.
93
+ * @returns {string}
94
+ */
95
+ function cleanBlock(inner) {
96
+ const lines = inner
97
+ .split('\n')
98
+ // Drop a leading ` * ` (or bare ` *`) from each line; preserve content indentation.
99
+ .map((line) => line.replace(/^[ \t]*\*[ \t]?/, '').replace(/\s+$/, ''));
100
+
101
+ // Trim leading/trailing blank lines.
102
+ while (lines.length && lines[0] === '') lines.shift();
103
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
104
+
105
+ return lines.join('\n');
106
+ }
107
+
108
+ /**
109
+ * Case-insensitive lookup of the first matching label.
110
+ * @param {Record<string, string>} sections
111
+ * @param {string[]} candidates
112
+ * @returns {string | null}
113
+ */
114
+ function findSection(sections, candidates) {
115
+ const wanted = candidates.map((c) => c.toUpperCase());
116
+ for (const [label, text] of Object.entries(sections)) {
117
+ if (wanted.includes(label.toUpperCase())) return text;
118
+ }
119
+ return null;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -86,7 +86,7 @@
86
86
  "dotenv": "^16.4.5",
87
87
  "hyparquet-writer": "^0.6.1",
88
88
  "mixpanel": "^0.18.0",
89
- "mixpanel-import": "^3.3.1",
89
+ "mixpanel-import": "^3.3.2",
90
90
  "p-limit": "^3.1.0",
91
91
  "pino": "^9.0.0",
92
92
  "pino-pretty": "^11.0.0",
@@ -107,4 +107,4 @@
107
107
  "tmp/"
108
108
  ]
109
109
  }
110
- }
110
+ }
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Converts a JavaScript dungeon file to JSON that can be loaded into the UI
4
+ * Converts a JavaScript dungeon file to JSON that can be loaded into the UI.
5
+ * Thin CLI wrapper around the exported `dungeonToJSON` (lib/core/dungeon-to-json.js).
5
6
  * Usage: node scripts/dungeon-to-json.js <input.js> [output-name]
6
7
  */
7
8
 
8
9
  import { writeFileSync } from 'fs';
9
10
  import path from 'path';
10
- import { fileURLToPath } from 'url';
11
-
12
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ import { dungeonToJSON } from '../lib/core/dungeon-to-json.js';
13
12
 
14
13
  // Get command line arguments
15
14
  const args = process.argv.slice(2);
@@ -25,34 +24,10 @@ const inputPath = path.resolve(args[0]);
25
24
  const outputName = args[1] || path.basename(inputPath, '.js');
26
25
 
27
26
  try {
28
- // Import the JavaScript module
29
27
  console.log(`📖 Loading ${inputPath}...`);
30
- const module = await import(`file://${inputPath}`);
31
- const config = module.default;
32
-
33
- if (!config) {
34
- throw new Error('No default export found in the module');
35
- }
36
-
37
- // Extract hooks if they exist
38
- const hooksFunction = config.hook;
39
- const hooksString = hooksFunction ? hooksFunction.toString() : null;
40
-
41
- // Create a clean config without the hook function
42
- const cleanConfig = { ...config };
43
- delete cleanConfig.hook;
44
-
45
- // Convert to JSON-serializable format
46
28
  console.log('🔄 Converting to JSON format...');
47
- const schema = convertToJSON(cleanConfig);
48
-
49
- // Create the dungeon state object (same format as UI saves)
50
- const dungeonState = {
51
- schema,
52
- hooks: hooksString,
53
- timestamp: new Date().toISOString(),
54
- version: '4.0'
55
- };
29
+ // includeCredentials: true preserves the legacy UI round-trip behavior (full config).
30
+ const dungeonState = await dungeonToJSON(inputPath, { includeCredentials: true });
56
31
 
57
32
  // Write to JSON file
58
33
  const outputPath = path.join(path.dirname(inputPath), `${outputName}.json`);
@@ -67,97 +42,3 @@ try {
67
42
  console.error(error.stack);
68
43
  process.exit(1);
69
44
  }
70
-
71
- /**
72
- * Convert JavaScript config to JSON-serializable format
73
- * Detects functions and converts them to object representation
74
- */
75
- function convertToJSON(value) {
76
- // Null/undefined
77
- if (value === null || value === undefined) {
78
- return null;
79
- }
80
-
81
- // Primitives
82
- if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
83
- return value;
84
- }
85
-
86
- // Functions - convert to object representation
87
- if (typeof value === 'function') {
88
- return convertFunctionToObject(value);
89
- }
90
-
91
- // Arrays
92
- if (Array.isArray(value)) {
93
- return value.map(item => convertToJSON(item));
94
- }
95
-
96
- // Objects
97
- if (typeof value === 'object') {
98
- const result = {};
99
- for (const [key, val] of Object.entries(value)) {
100
- result[key] = convertToJSON(val);
101
- }
102
- return result;
103
- }
104
-
105
- // Fallback
106
- return null;
107
- }
108
-
109
- /**
110
- * Convert a function to its object representation
111
- * Tries to detect the function type and extract parameters
112
- */
113
- function convertFunctionToObject(func) {
114
- const funcString = func.toString();
115
-
116
- // Arrow function
117
- if (funcString.startsWith('(') || funcString.startsWith('_') || funcString.includes('=>')) {
118
- return {
119
- functionName: 'arrow',
120
- body: funcString
121
- };
122
- }
123
-
124
- // Bound chance methods (e.g., chance.name.bind(chance))
125
- if (funcString.includes('.bind(')) {
126
- const match = funcString.match(/chance\.(\w+)\.bind/);
127
- if (match) {
128
- return {
129
- functionName: `chance.${match[1]}`,
130
- args: []
131
- };
132
- }
133
- }
134
-
135
- // Try to detect common utility functions
136
- // This is a best-effort approach - some complex functions might not be detected
137
- const commonFunctions = [
138
- 'weighNumRange',
139
- 'weighArray',
140
- 'weighChoices',
141
- 'pickAWinner',
142
- 'date',
143
- 'integer',
144
- 'uid',
145
- 'comma'
146
- ];
147
-
148
- for (const fnName of commonFunctions) {
149
- if (funcString.includes(fnName)) {
150
- // Extract args (this is simplified - real parsing would be more complex)
151
- return {
152
- functionName: fnName,
153
- args: [] // Args would need to be extracted, but that's complex
154
- };
155
- }
156
- }
157
-
158
- // Generic function - just store as arrow function
159
- return {
160
- functionName: 'arrow',
161
- body: funcString
162
- };
163
- }
package/types.d.ts CHANGED
@@ -1508,6 +1508,79 @@ export declare function parseJSONDungeon(json: object): Dungeon;
1508
1508
  /** Validate that an object has the minimum shape of a dungeon config. Throws on shape violations. */
1509
1509
  export declare function validateDungeonShape(config: unknown): void;
1510
1510
 
1511
+ /**
1512
+ * The serialized form of a function found in a dungeon schema. Produced by `dungeonToJSON`
1513
+ * and revived by `parseJSONDungeon`.
1514
+ *
1515
+ * - `functionName: "arrow"` with a `body` — an inline/closure function, re-eval'd on revive.
1516
+ * - `functionName: "chance.<method>"` — a bound chance method, e.g. `chance.name.bind(chance)`.
1517
+ * - `functionName: "<utility>"` (weighNumRange, weighArray, …) — a detected utility; args are
1518
+ * not recoverable from the stringified source, so it revives to null (best effort).
1519
+ *
1520
+ * `dataType` records the function's sampled output type so the field's type is preserved even
1521
+ * when the generator itself can't be revived.
1522
+ */
1523
+ export interface SerializedFunction {
1524
+ /** "arrow", "chance.<method>", or a known utility name. */
1525
+ functionName: string;
1526
+ /** Stringified function source (present for arrow/closure forms). */
1527
+ body?: string;
1528
+ /** Captured call arguments (best effort; usually empty for detected utilities). */
1529
+ args?: unknown[];
1530
+ /**
1531
+ * Inferred output type, sampled at serialization time:
1532
+ * "number" | "string" | "boolean" | "date" | "object" | "<elementType>[]" | "array".
1533
+ * Omitted when sampling failed (e.g. the function threw).
1534
+ */
1535
+ dataType?: string;
1536
+ }
1537
+
1538
+ /**
1539
+ * The JSON/UI wrapper representation of a dungeon, as produced by `dungeonToJSON` and
1540
+ * consumed by `parseJSONDungeon`. Functions in `schema` are serialized to
1541
+ * {@link SerializedFunction} objects; everything else is plain JSON.
1542
+ */
1543
+ export interface DungeonJSON {
1544
+ /** The dungeon config with functions converted to {@link SerializedFunction} objects (hook excluded). */
1545
+ schema: Record<string, unknown>;
1546
+ /** The `hook` function stringified, or null if the dungeon has no hook. */
1547
+ hooks: string | null;
1548
+ /** ISO timestamp of when the JSON was produced. */
1549
+ timestamp: string;
1550
+ /** UI schema format version. */
1551
+ version: string;
1552
+ }
1553
+
1554
+ /** The doc blocks extracted from a dungeon's source by `extractComments`. */
1555
+ export interface DungeonComments {
1556
+ /** Cleaned text of the `// ── OVERVIEW ──` block, or null if absent. */
1557
+ overview: string | null;
1558
+ /** Cleaned text of the `// ── HOOK STORIES ──` block, or null if absent. */
1559
+ hookStories: string | null;
1560
+ /** Every `// ── LABEL ──` header followed by a block comment, keyed by exact label. */
1561
+ sections: Record<string, string>;
1562
+ }
1563
+
1564
+ /**
1565
+ * Convert a dungeon into its JSON representation (the inverse of `parseJSONDungeon`).
1566
+ * Accepts a config object, a file path, raw JS source, or an array of file paths
1567
+ * (returns an array). Credentials are stripped unless `includeCredentials` is set.
1568
+ * Best effort: arrow functions and `chance.*` methods round-trip; detected utility
1569
+ * calls lose their arguments. Always async.
1570
+ */
1571
+ export declare function dungeonToJSON(input: Dungeon, options?: { includeCredentials?: boolean }): Promise<DungeonJSON>;
1572
+ export declare function dungeonToJSON(input: string, options?: { includeCredentials?: boolean }): Promise<DungeonJSON>;
1573
+ export declare function dungeonToJSON(input: string[], options?: { includeCredentials?: boolean }): Promise<DungeonJSON[]>;
1574
+
1575
+ /**
1576
+ * Extract the human-readable doc blocks (OVERVIEW, HOOK STORIES, …) from a dungeon's
1577
+ * SOURCE. Operates on a file path or raw source string — never imports the dungeon
1578
+ * (importing discards comments). Best effort: relies on the canonical `// ── LABEL ──`
1579
+ * header + block-comment convention.
1580
+ */
1581
+ export declare function extractComments(input: string): DungeonComments;
1582
+ export declare function extractComments(input: string[]): DungeonComments[];
1583
+
1511
1584
  // ============= Text Generator Types =============
1512
1585
 
1513
1586
  /**