@ak--47/dungeon-master 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude/skills/powertools/SKILL.md +75 -0
  2. package/.claude/skills/powertools/pt.mjs +91 -0
  3. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  4. package/CHANGELOG.md +138 -0
  5. package/README.md +2 -0
  6. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +4 -5
  7. package/dungeons/vertical/community/community.verify.mjs +4 -6
  8. package/dungeons/vertical/crypto/crypto.verify.mjs +4 -2
  9. package/dungeons/vertical/dating/dating.verify.mjs +4 -6
  10. package/dungeons/vertical/devtools/devtools.verify.mjs +4 -2
  11. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +4 -4
  12. package/dungeons/vertical/education/education.verify.mjs +4 -9
  13. package/dungeons/vertical/fintech/fintech.verify.mjs +4 -4
  14. package/dungeons/vertical/fitness/fitness.verify.mjs +4 -5
  15. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +4 -2
  16. package/dungeons/vertical/gaming/gaming.verify.mjs +4 -4
  17. package/dungeons/vertical/healthcare/healthcare.verify.mjs +4 -6
  18. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +4 -2
  19. package/dungeons/vertical/logistics/logistics.verify.mjs +4 -7
  20. package/dungeons/vertical/marketplace/marketplace.verify.mjs +4 -2
  21. package/dungeons/vertical/media/media.verify.mjs +4 -2
  22. package/dungeons/vertical/real-estate/real-estate.verify.mjs +4 -9
  23. package/dungeons/vertical/sass/sass.verify.mjs +4 -2
  24. package/dungeons/vertical/social/social.verify.mjs +4 -2
  25. package/dungeons/vertical/streaming/streaming.verify.mjs +4 -2
  26. package/dungeons/vertical/support-desk/support-desk.verify.mjs +4 -2
  27. package/dungeons/vertical/travel/travel.verify.mjs +4 -6
  28. package/index.js +35 -2
  29. package/lib/core/config-validator.js +19 -1
  30. package/lib/orchestrators/mixpanel-sender.js +52 -2
  31. package/lib/utils/utils.js +146 -55
  32. package/lib/verify/index.js +6 -0
  33. package/lib/verify/verify-dungeon.js +39 -12
  34. package/package.json +4 -4
  35. package/scripts/verify-stories.mjs +2 -1
  36. package/types.d.ts +17 -0
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: powertools
3
+ description: Use when any task needs the Mixpanel Power Tools API ("use powertools") — schema export (get-schema), event volumes, project CRUD, query methods, macros, or snapshotting a prod project's schema to copy it into a dungeon. Companion to create-project (which handles provisioning specifically).
4
+ argument-hint: [what to do, e.g. "get schema for project 12345" or "copy project 12345 into a dungeon"]
5
+ ---
6
+
7
+ # Power Tools API
8
+
9
+ Base URL: `https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app`
10
+
11
+ ## Auth model — read this first
12
+
13
+ - **GET any endpoint path = documentation, no auth.** Always `curl -s GET <base><path>` before first use of an unfamiliar endpoint — docs include exact body params and response shapes.
14
+ - **POST = execute.** `Authorization: Bearer <oauth-token>` (employee OAuth from repo `.env` `BEARER_TOKEN`, or a customer's OAuth token) or `Basic base64(service_acct:secret)`.
15
+ - **Customer OAuth tokens are accepted** (verified 2026-07-06): `/auth` and `/macro/get-schema` work with a customer token on projects that token can access. `ai_endpoints_allowed: false` for non-employees — the `ai-*` family stays employee-only.
16
+ - **Every POST body** should include `client_id: "dungeon-master"` and `region` (`US` default).
17
+ - **`/auth` accessibility ≠ data access.** `/auth {project_id}` can report `accessible: true` while `get-schema`/`query/*` on the same project return `HTTP 403: Forbidden` (token lacks data-level access, e.g. an employee token on a customer project). Diagnose with `/auth`, but don't trust it for data endpoints. Prefer the token of an actual project member.
18
+
19
+ ## Tools in this skill
20
+
21
+ ### `pt.mjs` — ad-hoc client
22
+
23
+ ```bash
24
+ node .claude/skills/powertools/pt.mjs <path> ['<json-body>'] [--bearer <token>] [--get] [--region US]
25
+ # examples
26
+ node .claude/skills/powertools/pt.mjs /auth '{}'
27
+ node .claude/skills/powertools/pt.mjs /macro/get-schema '{"project_id":"123","include_metadata":true,"verbose":true}'
28
+ node .claude/skills/powertools/pt.mjs /query/getTopEvents --get # docs, no auth
29
+ ```
30
+
31
+ Bearer defaults to `.env` `BEARER_TOKEN`. Merges `client_id`/`region` into the body. Prints pretty JSON to stdout.
32
+
33
+ ### `snapshot-project.mjs` — schema + relative-volume snapshot
34
+
35
+ ```bash
36
+ node .claude/skills/powertools/snapshot-project.mjs <project_id> --bearer <token> \
37
+ [--region US] [--out snapshot.json]
38
+ ```
39
+
40
+ - Uses `/macro/get-schema` (`include_metadata` + `verbose`) + `/query/getTopEvents`. Token (customer or employee) needs data access to the project.
41
+ - Output shape: `{ projectId, projectName, fetchedAt, totalCount, events: [{ name, count, pct, properties: [{name, type, description}] }], userProps: [{name, type, description}], groups: {} }`, events sorted by count desc.
42
+ - **Never captures property values** — schema + volumes only. Snapshots stay privacy-safe by construction.
43
+
44
+ ## Endpoint catalog (the useful subset)
45
+
46
+ GET the path for full docs. Full list: GET `/` and GET `/macro`.
47
+
48
+ **crud** — `/crud/createProject`, `/crud/deleteProject`, `/crud/getProjects`, `/crud/mintServiceAccount`, `/crud/addGroupKey`, `/crud/setBusinessContext` (all used by the create-project skill's `provision.mjs`).
49
+
50
+ **query** — `/query/getTopEvents` (per-event counts, limit≤100 default), `/query/getEventNames`, `/query/getPropertyValues`, `/query/getTopProperties`, `/query/getSegmentation`, `/query/getFunnel`, `/query/listFunnels`, `/query/listCohorts`, `/query/runJQL`. Rate limits: 5 concurrent / 60 per hour; 1h response cache.
51
+
52
+ **macros** — `/macro/get-schema` (always pass `include_metadata: true, verbose: true`; also `include_density: true` for per-(event,property) coverage % and `include_sdk_defaults: true` to keep `$browser`/`$os`-style SDK props — needed for carbon copies), `/macro/analyze-project` (volumes + cardinality + activity), `/macro/enumerate-project`, `/macro/clone-project`, `/macro/clone-boards`, `/macro/delete-entities`, `/macro/dungeon-master` (runs a dungeon config server-side and ingests), `/macro/ai-e2e-dm4` (demo build from a supplied schema, designed for dungeon-master), plus the `ai-*` family (dashboards, cohorts, metrics, schema naming).
53
+
54
+ ### get-schema field notes (verified on a 2,410-event project, 2026-07-06)
55
+
56
+ - Response nests under `json`: `{ json: { events, properties, users, groups, dependencies }, duration_ms }`. Large projects are slow — 2,410 events took ~13 min; run it in the background.
57
+ - Event counts include custom events (`customEventId > 0`) and merged events. Filter `!merged && !(customEventId > 0)` to match the Lexicon UI event count.
58
+ - `properties` includes `mp_*` internals and `$custom_property:<id>` computed-prop references — exclude both when authoring a dungeon (ingestion stamps `mp_*` itself; computed props can't be tracked).
59
+ - `exampleValue` fields contain REAL customer values — never copy them into a dungeon.
60
+ - **Known issue**: `dependencies` can come back empty (`{events:{},properties:{}}`) on large projects even with `verbose: true` + `include_density: true` — the bulk dependency call silently fails. Retry the call; per-event property mapping is unavailable until it succeeds.
61
+
62
+ ## Recipe: copy a prod project into a dungeon
63
+
64
+ Goal: a purely synthetic dungeon with the same events/props/user-props and matching **relative** event volumes. Safe to share — no customer data.
65
+
66
+ 1. **Snapshot** the source project (`snapshot-project.mjs`, customer token unless the employee token has data access). Sanity-check event count + that counts are non-zero.
67
+ 2. **Author the dungeon** from the snapshot (schema-first, per repo hook rules):
68
+ - Take the top-N events covering ≥95% of total volume (`pct` cumsum); note dropped tail in the OVERVIEW comment.
69
+ - `weight` per event ∝ snapshot `count` (normalize so max ≈ 100, min ≥ 1).
70
+ - Properties per event from snapshot; **invent all values** from name/type/description — never copy real values.
71
+ - `userProps` from snapshot; shared high-frequency props → `superProps`.
72
+ - Funnels: best-effort from event-name semantics (or `/query/listFunnels` + `/query/getFunnel` on the source if accessible).
73
+ 3. **Provision** into our org via the create-project skill: `node .claude/skills/create-project/provision.mjs <dungeon> --dry-run` → confirm → live. Uses `.env` `BEARER_TOKEN` + `ORG_ID`, writes `credentials` back into the dungeon.
74
+ 4. **Run**: `node scripts/run-dungeon.mjs <dungeon>`.
75
+ 5. **Verify** relative volumes: top-10 generated events should rank in the same order as the snapshot's top-10.
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * pt.mjs — thin ad-hoc client for the Mixpanel Power Tools API.
5
+ *
6
+ * Usage:
7
+ * node .claude/skills/powertools/pt.mjs <path> ['<json-body>'] [--bearer <token>] [--get] [--region US]
8
+ *
9
+ * Examples:
10
+ * node .claude/skills/powertools/pt.mjs /auth '{}'
11
+ * node .claude/skills/powertools/pt.mjs /macro/get-schema '{"project_id":"123","include_metadata":true,"verbose":true}'
12
+ * node .claude/skills/powertools/pt.mjs /query/getTopEvents --get # endpoint docs, no auth
13
+ *
14
+ * POST bodies are merged with { client_id: "dungeon-master", region }.
15
+ * Bearer defaults to BEARER_TOKEN in the repo .env (employee OAuth). Customer
16
+ * OAuth tokens are accepted on non-ai endpoints; the ai-* family is
17
+ * employee-only (see SKILL.md).
18
+ */
19
+
20
+ import { dirname, resolve } from 'path';
21
+ import { fileURLToPath } from 'url';
22
+ import dotenv from 'dotenv';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ dotenv.config({ path: resolve(__dirname, '../../../.env') });
26
+
27
+ const BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
28
+ const CLIENT_ID = 'dungeon-master';
29
+
30
+ const args = process.argv.slice(2);
31
+ const getMode = popFlag('--get');
32
+ const bearer = popOpt('--bearer') ?? process.env.BEARER_TOKEN;
33
+ const region = popOpt('--region') ?? 'US';
34
+ const [pathArg, bodyArg] = args;
35
+
36
+ if (!pathArg || !pathArg.startsWith('/')) {
37
+ console.error('Usage: node pt.mjs <path starting with /> [\'<json-body>\'] [--bearer <token>] [--get] [--region US]');
38
+ process.exit(1);
39
+ }
40
+
41
+ if (getMode) {
42
+ const res = await fetch(BASE + pathArg);
43
+ console.log(JSON.stringify(await res.json(), null, 2));
44
+ process.exit(res.ok ? 0 : 1);
45
+ }
46
+
47
+ if (!bearer) {
48
+ console.error('No bearer token: pass --bearer or set BEARER_TOKEN in .env');
49
+ process.exit(1);
50
+ }
51
+
52
+ let body = {};
53
+ if (bodyArg) {
54
+ try {
55
+ body = JSON.parse(bodyArg);
56
+ } catch (err) {
57
+ console.error(`Body is not valid JSON: ${err.message}`);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ const res = await fetch(BASE + pathArg, {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
65
+ body: JSON.stringify({ client_id: CLIENT_ID, region, ...body }),
66
+ });
67
+
68
+ const text = await res.text();
69
+ try {
70
+ console.log(JSON.stringify(JSON.parse(text), null, 2));
71
+ } catch {
72
+ console.log(text);
73
+ }
74
+ if (!res.ok) {
75
+ console.error(`\nHTTP ${res.status} ${res.statusText}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ function popFlag(name) {
80
+ const i = args.indexOf(name);
81
+ if (i === -1) return false;
82
+ args.splice(i, 1);
83
+ return true;
84
+ }
85
+
86
+ function popOpt(name) {
87
+ const i = args.indexOf(name);
88
+ if (i === -1) return undefined;
89
+ const [, value] = args.splice(i, 2);
90
+ return value;
91
+ }
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * snapshot-project.mjs — export a Mixpanel project's schema + relative event
5
+ * volumes into one normalized JSON snapshot, for authoring a synthetic
6
+ * dungeon-master "copy" of the project.
7
+ *
8
+ * Usage:
9
+ * node .claude/skills/powertools/snapshot-project.mjs <project_id> --bearer <token> \
10
+ * [--region US|EU|IN] [--out <file>]
11
+ *
12
+ * Uses the Power Tools API: /macro/get-schema (include_metadata + verbose) +
13
+ * /query/getTopEvents. The bearer token (customer or employee OAuth) needs
14
+ * data access to the project — /auth reporting accessible: true is not enough.
15
+ *
16
+ * Output shape (events sorted by count desc):
17
+ * {
18
+ * projectId, projectName, fetchedAt, region, totalCount,
19
+ * events: [{ name, count, pct, properties: [{ name, type, description }] }],
20
+ * userProps: [{ name, type, description }],
21
+ * groups: {}
22
+ * }
23
+ *
24
+ * Privacy: captures schema + volumes ONLY — never property values.
25
+ */
26
+
27
+ import { writeFileSync } from 'fs';
28
+ import { dirname, resolve } from 'path';
29
+ import { fileURLToPath } from 'url';
30
+ import dotenv from 'dotenv';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ dotenv.config({ path: resolve(__dirname, '../../../.env') });
34
+
35
+ const PT_BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
36
+ const CLIENT_ID = 'dungeon-master';
37
+ const REGIONS = ['US', 'EU', 'IN'];
38
+
39
+ const args = process.argv.slice(2);
40
+ const bearer = popOpt('--bearer') ?? process.env.BEARER_TOKEN;
41
+ const region = (popOpt('--region') ?? 'US').toUpperCase();
42
+ const outArg = popOpt('--out');
43
+ const projectId = args.find((a) => !a.startsWith('--'));
44
+
45
+ if (!projectId || !/^\d+$/.test(projectId)) fail('Usage: snapshot-project.mjs <project_id> --bearer <token> [--region US] [--out file]');
46
+ if (!bearer) fail('No bearer token: pass --bearer or set BEARER_TOKEN in .env');
47
+ if (!REGIONS.includes(region)) fail(`Unknown --region "${region}"`);
48
+
49
+ const outPath = resolve(process.cwd(), outArg ?? `snapshot-${projectId}.json`);
50
+
51
+ const snapshot = await snapshotViaPowertools();
52
+
53
+ snapshot.events.sort((a, b) => b.count - a.count);
54
+ snapshot.totalCount = snapshot.events.reduce((s, e) => s + e.count, 0);
55
+ for (const e of snapshot.events) e.pct = snapshot.totalCount ? +(e.count / snapshot.totalCount * 100).toFixed(4) : 0;
56
+
57
+ writeFileSync(outPath, JSON.stringify(snapshot, null, 2));
58
+
59
+ const zeros = snapshot.events.filter((e) => e.count === 0).length;
60
+ console.log(`✓ snapshot → ${outPath}`);
61
+ console.log(` project: ${snapshot.projectName ?? '(name unknown)'} (${snapshot.projectId})`);
62
+ console.log(` events: ${snapshot.events.length} (${zeros} with zero volume)`);
63
+ console.log(` userProps: ${snapshot.userProps.length}`);
64
+ console.log(` total vol: ${snapshot.totalCount.toLocaleString()} events`);
65
+ console.log(' top 10:');
66
+ for (const e of snapshot.events.slice(0, 10)) console.log(` ${e.pct.toFixed(2).padStart(6)}% ${e.count.toLocaleString().padStart(12)} ${e.name}`);
67
+
68
+ async function snapshotViaPowertools() {
69
+ const schema = await ptPost('/macro/get-schema', { project_id: projectId, include_metadata: true, verbose: true });
70
+ const top = await ptPost('/query/getTopEvents', { project_id: projectId, limit: 500 });
71
+
72
+ const counts = {};
73
+ for (const r of top.results ?? []) counts[r.event] = r.count;
74
+
75
+ // dependencies.events maps eventName → [propertyNames]; property defs live in schema.properties
76
+ const propDefs = new Map();
77
+ for (const p of schema.properties ?? []) propDefs.set(p.name, p);
78
+ const deps = schema.dependencies?.events ?? {};
79
+
80
+ const events = (schema.events ?? []).map((ev) => {
81
+ const name = ev.name ?? ev;
82
+ const propNames = deps[name] ?? [];
83
+ return {
84
+ name,
85
+ count: counts[name] ?? 0,
86
+ pct: 0,
87
+ properties: propNames.map((pn) => {
88
+ const def = propDefs.get(pn) ?? {};
89
+ return { name: pn, type: def.type ?? 'string', description: def.description ?? '' };
90
+ }),
91
+ };
92
+ });
93
+
94
+ const userProps = (schema.users ?? []).map((p) => ({
95
+ name: p.name ?? p,
96
+ type: p.type ?? 'string',
97
+ description: p.description ?? '',
98
+ }));
99
+
100
+ return { projectId, projectName: null, fetchedAt: new Date().toISOString(), region, totalCount: 0, events, userProps, groups: schema.groups ?? {} };
101
+ }
102
+
103
+ async function ptPost(pathname, body) {
104
+ const res = await fetch(PT_BASE + pathname, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
107
+ body: JSON.stringify({ client_id: CLIENT_ID, region, ...body }),
108
+ });
109
+ const json = await res.json().catch(() => ({}));
110
+ if (!res.ok) throw new Error(`POST ${pathname} → HTTP ${res.status}: ${json.error ?? JSON.stringify(json).slice(0, 300)}`);
111
+ return json;
112
+ }
113
+
114
+ function popOpt(name) {
115
+ const i = args.indexOf(name);
116
+ if (i === -1) return undefined;
117
+ const [, value] = args.splice(i, 2);
118
+ return value;
119
+ }
120
+
121
+ function fail(msg) {
122
+ console.error(`✖ ${msg}`);
123
+ process.exit(1);
124
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,144 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.6.2 — 2026-07-30
6
+
7
+ ### Fixed
8
+
9
+ - **Running the same dungeon twice in one process no longer collapses the second
10
+ run.** `validateDungeonConfig` enriched in place — stamping `isStrictEvent` on
11
+ funnel-step events and `conversionWindowDays` / `_experiment` on funnels — and
12
+ `DUNGEON_MASTER` handed the pipeline a shallow spread, so those stamps landed on
13
+ the caller's own `events` / `funnels` arrays. For a file input that array belongs
14
+ to the ESM module cache, so run 2 got a config already enriched by run 1: every
15
+ event pre-promoted to strict, the catch-all funnel swept nothing, and event volume
16
+ collapsed (measured 317 → 0 on `dungeons/technical/simple.js`). The validator now
17
+ clones its input and enriches only what it returns; functions (`hook`,
18
+ `onProgress`, chance-bound prop thunks) are preserved by reference. Affects an
19
+ object config passed by a caller who reuses it, a file path run more than once in
20
+ a process, and an array of paths. Not affected: raw-text input (each call writes a
21
+ fresh temp module) and `scripts/run-many.mjs` (forks a child per dungeon). Pinned
22
+ by `tests/integration/config-isolation.test.js`.
23
+ - **`verifyDungeon` now applies funnel config to path and JSON inputs.** It read
24
+ `conversionWindowDays` / `order` back off the caller's own `config`, which for a
25
+ string input has no `.funnels` at all — so every funnel check silently ran with
26
+ the default order and an unbounded window. It now reads
27
+ `result.validatedConfig.funnels`.
28
+ - **`verifyDungeon`'s schema report is no longer spurious.** `validateSchema` also
29
+ ran against the raw input: a path input has no fields to derive an expected
30
+ schema from, and a v1.5.1 dungeon keeps `hasAndroidDevices` / `hasBrowser` under
31
+ `switches`, which `deriveExpectedSchema` only sees once flattened. Both produced
32
+ a wall of phantom `flagStamping` findings and a permanently false `report.pass`
33
+ (16 phantom findings on `dungeons/technical/experiments.js`; now 0). It now
34
+ validates against the config the run actually used.
35
+ - **The 22 shipped `dungeons/vertical/*/*.verify.mjs` wrappers now thread VALIDATED
36
+ funnels** into `evaluateStories`. They passed `config.funnels` raw, so no funnel
37
+ story in any vertical had a conversion window — every vertical funnel resolves to
38
+ a 30- or 45-day `conversionWindowDays` that was being dropped. Pre-existing (these
39
+ scripts read shards off disk, so nothing ever enriched their config).
40
+ `validateDungeonConfig` is now exported from `@ak--47/dungeon-master/verify` for
41
+ exactly this.
42
+ - **`verifyDungeon` throws on a multi-dungeon input** instead of silently verifying
43
+ `result[0]` and discarding the rest — which returned a green report for dungeons
44
+ nobody looked at. Call it once per dungeon.
45
+ - **`tests/unit/dungeon-shapes.test.js` no longer asserts the lowercase-hyphen naming
46
+ convention against `dungeons/user/`.** That directory is gitignored per-machine
47
+ scratch space, so the check failed on whatever a given developer had checked out
48
+ locally and was unreproducible in CI. The convention still applies to the tracked
49
+ `technical/` and `vertical/` dungeons.
50
+
51
+ ### Added
52
+
53
+ - **`result.validatedConfig`** — the enriched config the run actually used. Read
54
+ resolved values (`funnels[].conversionWindowDays`, `events[].isStrictEvent`, the
55
+ resolved dataset window) here now that the validator no longer writes them back to
56
+ the object you passed in. Two caveats, both documented on the type: credentials
57
+ are stripped (a Result gets logged), and it is **read-only** — validation is not
58
+ idempotent, so feeding it back into `DUNGEON_MASTER` grows the funnel set and
59
+ eventually yields an empty `sequence`. Re-run the original config instead.
60
+ - **`verifyDungeon(config, checks, overrides)`** — an optional third argument,
61
+ merged into the dungeon before it runs exactly like `DUNGEON_MASTER`'s second
62
+ argument. Lets CI verify a production-scale dungeon at a small `numUsers` /
63
+ `numEvents` without editing it. The report also carries `validatedConfig`.
64
+ - **`validateDungeonConfig`** re-exported from `@ak--47/dungeon-master/verify`, so a
65
+ standalone verify script that reads shards off disk can resolve funnel defaults
66
+ before calling `evaluateStories` / `applyFunnelDefaults`.
67
+ - **`releaseConnections()`** exported from `lib/orchestrators/mixpanel-sender.js`,
68
+ for hosts that drive `mixpanel-import` directly and want the same pool teardown.
69
+
70
+ ### Changed
71
+
72
+ - **`validateDungeonConfig` no longer mutates its input.** Enrichment lands only on
73
+ the returned object. If you called it directly and then read `isStrictEvent` /
74
+ `conversionWindowDays` back off the config you passed in, read the return value
75
+ instead (or `result.validatedConfig` after a run). Running a dungeon is
76
+ unaffected. This is the fix for the collapse bug above, so it ships on a patch.
77
+ - **`mixpanel-import` bumped `^3.3.2` → `^3.5.1`.** Notable for dungeon-master:
78
+ - **Flat events past `epochEnd` no longer kill the entire import job.** The sender
79
+ sets `epochEnd: dayjs().unix()` and dungeon-master events are flat, which on
80
+ 3.3.2 threw `Record has no properties object, cannot fix time` and failed the
81
+ whole batch; 3.5.1 counts the record as `outOfBounds` and carries on. Verified
82
+ directly against both versions.
83
+ - **India-region SCD imports go to `api-in`.** 3.3.2 routed `region: 'IN'` SCD
84
+ batches to `api-eu`.
85
+ - **The library no longer installs five process-global handlers**
86
+ (`unhandledRejection`, `uncaughtException`, `exit`, `SIGINT`, `SIGTERM`) as an
87
+ import side effect. **Hosts embedding dungeon-master will now crash on uncaught
88
+ exceptions and unhandled rejections instead of logging and continuing** — those
89
+ errors were always happening, only the reporting changes. Register your own
90
+ handlers to restore the old behavior. Upside: `user-loop`'s own SIGINT handler is
91
+ no longer preempted, so Ctrl+C cancellation works as designed.
92
+ - Prod-only `npm audit` for the dependency tree: 45 findings → 20 (critical 3 → 1).
93
+ - **`sendToMixpanel` releases mixpanel-import's shared undici connection pools**
94
+ when it settles, so a host that runs occasional imports doesn't hold ingest
95
+ sockets open in between. Runs in a `finally`, guarded, and non-fatal; pools are
96
+ recreated on demand. The pools are process-global, so teardown is refcounted —
97
+ concurrent `DUNGEON_MASTER()` calls in one process won't close sockets out from
98
+ under each other.
99
+ - **`engines.node` raised `>=18.0.0` → `>=20.20.0`**, matching what mixpanel-import
100
+ requires. The old floor had been wrong since 3.3.2 (which already wanted 20.18.1).
101
+
102
+ ## 1.6.1 — 2026-07-08
103
+
104
+ ### Fixed
105
+
106
+ - **Unweighted string arrays now produce stable power-law distributions**
107
+ (the long-documented intent finally works). Previously `choose()` rolled a
108
+ fresh random winner on every event — two compounding bugs (`!mostChosenIndex`
109
+ discarding an explicit `0`, plus a fresh `pickAWinner` closure per event) —
110
+ so aggregate breakdowns came out uniform (±edge noise). Now any plain array
111
+ of 3–19 unique strings gets ONE seed-deterministic winner per array per run,
112
+ drawn as ~45% winner / ~25% second / ~15% third / geometric-decay tail
113
+ (`winnerWeights` in `lib/utils/utils.js` is the single tuning point).
114
+ - Existing opt-outs unchanged: arrays containing
115
+ `variant`/`group`/`experiment`/`population`, arrays with explicit
116
+ duplicate entries (dupes honored exactly), arrays of length ≤2 or ≥20,
117
+ and non-string values.
118
+ - Direct `u.pickAWinner([...])` use in dungeon files gets the same stable
119
+ memoized winner for the run; explicit `pickAWinner(arr, 0)` is honored now.
120
+ (Note: `pickAWinner` calls at module-import time in dungeon files execute
121
+ before the run's seed is applied — as before — so those winners are stable
122
+ within a run but not pinned across processes.) Each `pickAWinner` resolver
123
+ now also carries its own expansion — previously `choose()`'s resolver cache
124
+ keyed by function source could serve one property's value list to a
125
+ different property.
126
+ - UTM properties (`utm_campaign` etc.) draw through the same path, so
127
+ campaign values now skew realistically per network instead of splitting
128
+ uniformly.
129
+ - New `resetValueCaches()` clears the per-run winner memo (and the
130
+ weighted-array resolver cache, which previously leaked across in-process
131
+ runs); called automatically by `initChance()` and at every run start.
132
+ - Side effect: ~1 RNG draw per string-array property per event instead of
133
+ ~30, and the seeded RNG stream shifts vs 1.6.0 — same seed no longer
134
+ reproduces 1.6.0 output byte-for-byte (within-version determinism is
135
+ unchanged).
136
+ - **Engine-shape canary and hook-pattern integration tests made truly
137
+ deterministic** — their generating tests now run `describe.sequential`,
138
+ since concurrent in-process `generate()` calls interleave draws (and
139
+ re-seeds) on the shared seeded chance. The hook-pattern negative control
140
+ was recalibrated to 600 users; at 150 it exceeded its own threshold on
141
+ 1.6.0 too and only passed under one lucky interleaving.
142
+
5
143
  ## 1.6.0 — 2026-07-04
6
144
 
7
145
  ### Added
package/README.md CHANGED
@@ -155,6 +155,8 @@ 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
+ **you usually don't need `pickAWinner`** — as of 1.6.1, any property value that is a plain array of 3–19 unique strings automatically gets a stable power-law distribution: one seed-deterministic winner per array per run (~45% winner / ~25% second / ~15% third / decaying tail). to opt out and get uniform draws, use exactly 2 values, 20+, or include one of the keywords `variant` / `group` / `experiment` / `population` in a value (experiment arms stay balanced). arrays with explicit duplicate entries (`["card", "card", "apple_pay"]`) skip the auto-weighting and honor the duplicates exactly.
159
+
158
160
  ### named exports
159
161
 
160
162
  alongside the default `DUNGEON_MASTER` export, the package root exports loader + interop helpers:
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './ai-platform.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-ai-platform';
@@ -51,12 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the H9 story carries its own explicit
55
- // 336-hour conversion window (2x the generative window, covering the
56
- // stretched support), so no funnel-default threading is needed
57
54
  const results = await evaluateStories(stories, events, {
58
55
  profiles,
59
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
60
59
  identityMap: buildIdentityMap(profiles),
61
60
  runSql,
62
61
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './community.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-community';
@@ -51,13 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the H8/H9 emulator stories carry their
55
- // own explicit conversion window (48h Content Creation generative window ×
56
- // the 1.25 free-tier stretch = 60h, covering the stretched support), so no
57
- // funnel-default threading is needed
58
54
  const results = await evaluateStories(stories, events, {
59
55
  profiles,
60
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
61
59
  identityMap: buildIdentityMap(profiles),
62
60
  runSql,
63
61
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './crypto.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-crypto';
@@ -63,7 +63,9 @@ const runSql = async (sql) => {
63
63
  // assemble chains across unscaled clones and collapse the read.
64
64
  const results = await evaluateStories(stories, events, {
65
65
  profiles,
66
- funnels: config.funnels,
66
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
67
+ // config — the dungeon was not run in this process, so validate here.
68
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
67
69
  identityMap: buildIdentityMap(profiles),
68
70
  runSql,
69
71
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './dating.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-dating';
@@ -51,13 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the H9 stories carry their own explicit
55
- // conversion window (24h Match Flow generative window × the 1.4 max stretch
56
- // factor = 33.6h, covering the stretched support), so no funnel-default
57
- // threading is needed
58
54
  const results = await evaluateStories(stories, events, {
59
55
  profiles,
60
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
61
59
  identityMap: buildIdentityMap(profiles),
62
60
  runSql,
63
61
  });
@@ -18,7 +18,7 @@ import path from 'node:path';
18
18
  import readline from 'node:readline';
19
19
  import { execFile } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
22
22
  import config, { stories } from './devtools.js';
23
23
 
24
24
  const PREFIX = process.argv[2] || 'verify-devtools';
@@ -58,7 +58,9 @@ const runSql = async (sql) => {
58
58
  // semantics, not cross-event SQL (greedy MIN→MIN picks flatten it).
59
59
  const results = await evaluateStories(stories, events, {
60
60
  profiles,
61
- funnels: config.funnels,
61
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
62
+ // config — the dungeon was not run in this process, so validate here.
63
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
62
64
  identityMap: buildIdentityMap(profiles),
63
65
  runSql,
64
66
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './ecommerce.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-ecommerce';
@@ -51,11 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the H8 story carries its own explicit
55
- // 75-minute pairing window in SQL, so no funnel-default threading is needed
56
54
  const results = await evaluateStories(stories, events, {
57
55
  profiles,
58
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
59
59
  identityMap: buildIdentityMap(profiles),
60
60
  runSql,
61
61
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './education.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-education';
@@ -51,16 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the emulator stories carry their own
55
- // explicit conversion windows (H7/H9: 86.4h = 48h generative × the 1.8 free
56
- // TTC stretch, on the 2-step enrolled→cert read; the 4-step doc funnel would
57
- // break because H9's annual ×0.5 compression can move a cert before the
58
- // interior quiz step). H10 pairs in SQL anchored at funnel ENTRY (first
59
- // 'discussion posted' at/after $experiment_started, conversion within 12h of
60
- // entry) because the exp→entry lag is arm-dependent.
61
54
  const results = await evaluateStories(stories, events, {
62
55
  profiles,
63
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
64
59
  identityMap: buildIdentityMap(profiles),
65
60
  runSql,
66
61
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './fintech.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-fintech';
@@ -51,11 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — every timeToConvert story carries an
55
- // explicit conversionWindowMs, so no funnel-default threading is needed
56
54
  const results = await evaluateStories(stories, events, {
57
55
  profiles,
58
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
59
59
  identityMap: buildIdentityMap(profiles),
60
60
  runSql,
61
61
  });