@ak--47/dungeon-master 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude/skills/create-project/provision.mjs +16 -3
  2. package/.claude/skills/headless-build/SKILL.md +254 -0
  3. package/.claude/skills/powertools/SKILL.md +21 -1
  4. package/CHANGELOG.md +152 -0
  5. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +4 -5
  6. package/dungeons/vertical/community/community.verify.mjs +4 -6
  7. package/dungeons/vertical/crypto/crypto.verify.mjs +4 -2
  8. package/dungeons/vertical/dating/dating.verify.mjs +4 -6
  9. package/dungeons/vertical/devtools/devtools.verify.mjs +4 -2
  10. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +4 -4
  11. package/dungeons/vertical/education/education.verify.mjs +4 -9
  12. package/dungeons/vertical/fintech/fintech.verify.mjs +4 -4
  13. package/dungeons/vertical/fitness/fitness.verify.mjs +4 -5
  14. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +4 -2
  15. package/dungeons/vertical/gaming/gaming.verify.mjs +4 -4
  16. package/dungeons/vertical/healthcare/healthcare.verify.mjs +4 -6
  17. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +4 -2
  18. package/dungeons/vertical/logistics/logistics.verify.mjs +4 -7
  19. package/dungeons/vertical/marketplace/marketplace.verify.mjs +4 -2
  20. package/dungeons/vertical/media/media.verify.mjs +4 -2
  21. package/dungeons/vertical/real-estate/real-estate.verify.mjs +4 -9
  22. package/dungeons/vertical/sass/sass.verify.mjs +4 -2
  23. package/dungeons/vertical/social/social.verify.mjs +4 -2
  24. package/dungeons/vertical/streaming/streaming.verify.mjs +4 -2
  25. package/dungeons/vertical/support-desk/support-desk.verify.mjs +4 -2
  26. package/dungeons/vertical/travel/travel.verify.mjs +4 -6
  27. package/index.js +30 -1
  28. package/lib/core/config-validator.js +19 -1
  29. package/lib/hook-helpers/_internal.js +45 -0
  30. package/lib/hook-helpers/inject.js +7 -7
  31. package/lib/hook-helpers/mutate.js +8 -6
  32. package/lib/hook-helpers/shape.js +4 -4
  33. package/lib/hook-patterns/frequency-by-frequency.js +2 -2
  34. package/lib/orchestrators/mixpanel-sender.js +52 -2
  35. package/lib/orchestrators/user-loop.js +27 -0
  36. package/lib/verify/index.js +6 -0
  37. package/lib/verify/verify-dungeon.js +39 -12
  38. package/package.json +4 -4
  39. package/scripts/verify-stories.mjs +2 -1
  40. package/types.d.ts +15 -0
@@ -315,9 +315,22 @@ function printSummary() {
315
315
  // Defensive: the API's `added`/`skipped` entries may be objects, strings, or
316
316
  // null. Never let summary formatting throw — it would swallow real warnings.
317
317
  const fmt = (g) => (g == null ? null : typeof g === 'string' ? g : (g.property_name || g.name || null));
318
- const added = (groupKeyResult?.added || []).map(fmt).filter(Boolean).join(', ') || '(none)';
319
- const skipped = (groupKeyResult?.skipped || []).map(fmt).filter(Boolean).join(', ') || '(none)';
320
- console.log(`group keys: added [${added}] skipped [${skipped}]`);
318
+ const added = (groupKeyResult?.added || []).map(fmt).filter(Boolean);
319
+ const skipped = (groupKeyResult?.skipped || []).map(fmt).filter(Boolean);
320
+ // `added`/`skipped` are not always populated (a create that lands via a
321
+ // different code path can return both empty), which made a perfectly good
322
+ // provision print "added [(none)] skipped [(none)]" and read as a failure.
323
+ // `all_group_keys` is the authoritative post-state, so report against that
324
+ // and only fall back to the deltas when it's absent.
325
+ const all = (groupKeyResult?.all_group_keys || []).map(fmt).filter(Boolean);
326
+ if (all.length) {
327
+ const present = groupKeys.map((g) => g.property_name).filter((p) => all.includes(p));
328
+ const missing = groupKeys.map((g) => g.property_name).filter((p) => !all.includes(p));
329
+ console.log(`group keys: in project [${present.join(', ') || '(none)'}]${missing.length ? ` MISSING [${missing.join(', ')}]` : ''}`);
330
+ if (missing.length) warnings.push(`group keys missing after addGroupKey: ${missing.join(', ')}`);
331
+ } else {
332
+ console.log(`group keys: added [${added.join(', ') || '(none)'}] skipped [${skipped.join(', ') || '(none)'}]`);
333
+ }
321
334
  }
322
335
  console.log(`business ctx: ${content.length} chars uploaded (source: ${ctxSource})`);
323
336
  console.log(`credentials: ${wroteBack ? 'written back into dungeon ✓' : 'NOT written (see warnings)'}`);
@@ -0,0 +1,254 @@
1
+ ---
2
+ name: headless-build
3
+ description: Use after a dungeon's data is loaded into a real Mixpanel project — builds the full demoable environment with mixpanel-headless (dashboards, charts, Lexicon, cohorts, custom properties, behaviors/metrics/formulas, annotations) targeted at that dungeon's engineered stories, then verifies the stories still read live. Final step after create-dungeon / write-hooks / verify-dungeon / create-project.
4
+ argument-hint: [dungeon path, e.g. dungeons/user/nyc-dcp/nyc-dcp.js]
5
+ model: claude-opus-4-6
6
+ effort: max
7
+ ---
8
+
9
+ # Headless Build — turn a loaded project into a demo
10
+
11
+ Last step of the pipeline:
12
+
13
+ ```
14
+ /create-dungeon → /write-hooks → /verify-dungeon → /create-project → /headless-build
15
+ ```
16
+
17
+ The project already has data. This skill builds everything a human sees: themed
18
+ dashboards whose narrative is computed from the live data, an annotated Lexicon,
19
+ saved cohorts and custom properties, saved behaviors/metrics/formulas, and
20
+ annotations that explain the engineered moments. Then it re-measures the hook
21
+ stories **against the live project** and fails if they no longer read.
22
+
23
+ That last step is the point. Local story verification passes on the generated
24
+ array in memory; it never sees what survived ingest. A build can render five
25
+ perfect dashboards on top of a story that silently collapsed on the way in.
26
+
27
+ ## Scope
28
+
29
+ In scope: dashboards + charts, Lexicon enrichment, cohorts, custom properties,
30
+ behaviors/metrics/formulas, annotations, and live story verification.
31
+
32
+ Out of scope: changing the dungeon, regenerating or re-sending data. If the data
33
+ is wrong, fix the dungeon and re-run `/create-project` — do not paper over it
34
+ with dashboard copy.
35
+
36
+ ## Prerequisites
37
+
38
+ - The dungeon's `creds.json` / `credentials` block exists (`/create-project` ran).
39
+ - Data is actually loaded — `00_auth_check.py` fails loudly if not.
40
+ - `uv` available. `mixpanel_headless` is on PyPI, so it needs no local checkout:
41
+ `uv run --with mixpanel_headless python <script>`.
42
+
43
+ ## Step 0 — ask who creates the entities
44
+
45
+ **Ask the user before building.** The answer changes who owns every asset:
46
+
47
+ | Mode | Effect |
48
+ |---|---|
49
+ | **bearer** (recommended) | Entities are created by a real user via OAuth token (`BEARER_TOKEN` in the repo `.env`). They appear in that person's Mixpanel UI with owner access. |
50
+ | **service** | Entities are created by the dungeon's service account. A human then sees **"Your access: None"**, and custom properties have no share endpoint — so it cannot be fixed from the UI at all. Only correct when no OAuth token exists. |
51
+
52
+ Default to **bearer** whenever a token is available. To use it with
53
+ `mixpanel_headless`, set `MP_OAUTH_TOKEN` and **unset `MP_USERNAME` / `MP_SECRET`** —
54
+ the SDK prefers the service account whenever the full SA env set is present.
55
+ Power-tools calls must use the same principal (`Authorization: Bearer …`), or
56
+ entities created through that path land back under the service account.
57
+
58
+ ## Reference implementation
59
+
60
+ `dungeons/user/nyc-dcp/build/` is the worked example — read it before writing a
61
+ new one. (It is gitignored with the rest of `dungeons/user/`, so it lives only in
62
+ the working copy.) Peloton's equivalent is
63
+ `/Users/ak/code/mixpanel-headless/ak/customer_peloton/`.
64
+
65
+ ## Layout
66
+
67
+ Create the build inside the dungeon's own folder:
68
+
69
+ ```
70
+ dungeons/user/<name>/build/
71
+ ├── .env # MP_USERNAME / MP_SECRET / MP_PROJECT_ID / MP_REGION
72
+ ├── _common.py # window detection, ws factory + retry, helpers, knobs, cohorts
73
+ ├── build_all.py # one-shot orchestrator, --only / --internal / --from-date
74
+ ├── scripts/
75
+ │ ├── 00_auth_check.py # also prints WHO is authenticated
76
+ │ ├── 01_reset_entities.py # opt-in: delete this build's own entities
77
+ │ ├── 02_custom_props.py
78
+ │ ├── 03_cohorts.py
79
+ │ ├── 04_lexicon.py
80
+ │ ├── 05_dash_start_here.py # built LAST — links the other boards by id
81
+ │ ├── 06..09_dash_<story>.py # one board per engineered story
82
+ │ ├── 10_annotations.py
83
+ │ ├── 11_behaviors_metrics.py # via power-tools; headless lacks these
84
+ │ └── 99_verify.py
85
+ └── results/ # entities.json registry, window.json, verification_*.json
86
+ ```
87
+
88
+ ## Steps
89
+
90
+ ### 1. Read the dungeon
91
+
92
+ Pull out, from the dungeon file itself: the `stories` export, the HOOK STORIES
93
+ doc block, the knob constants, the event/property schema, and the VALUE MOMENT.
94
+ **Mirror the knob constants into `_common.py`** so dashboard copy quotes the
95
+ designed value while the charts show the measured one. Never let narrative text
96
+ hardcode a number no longer tied to a knob.
97
+
98
+ ### 2. Probe the project before designing
99
+
100
+ Capabilities differ per project. Run a throwaway probe and design around what
101
+ actually works. Verified on both NYC DCP and Peloton:
102
+
103
+ - Insights `group_by` / `where` / saved-cohort filters: **work**.
104
+ - Funnels `group_by`: **silently does not segment** — returns rows identical to
105
+ ungrouped. Use one funnel per segment with a `where` filter instead.
106
+ - Day-granularity queries reject ranges **over 366 days**.
107
+ - Valid `displayOptions.chartType`: `bar`, `column`, `frequency-curve`,
108
+ `funnel-steps`, `funnel-top-paths`, `insights-metric`, `line`, `pie`,
109
+ `retention-curve`, `table`. There is no `stacked-area` — the API rejects it.
110
+
111
+ ### 3. Auto-detect the data window
112
+
113
+ Never hardcode dates. A regenerated dungeon lands on new ones and every chart
114
+ silently clips. Probe wide at `unit="month"`, then resolve exact days inside the
115
+ non-empty months (day charts cap at 366 days). Cache to `results/window.json`.
116
+
117
+ ### 4. Build entities, then boards
118
+
119
+ Order matters: custom properties and cohorts first (boards break down by them),
120
+ Lexicon, behaviors/metrics/formulas, annotations, then dashboards, with the
121
+ "Start Here" board last so it can link the others by id.
122
+
123
+ Keep a `results/entities.json` registry of every created id — the Start Here
124
+ board and the verifier both read it.
125
+
126
+ ### 5. Write boards that argue, not just render
127
+
128
+ The difference between a dashboard and a demo:
129
+
130
+ - **Query first, then narrate.** Compute the numbers live, then interpolate them
131
+ into text cards. Never write a number by hand.
132
+ - **One board per story**, plus a Start Here tour board.
133
+ - **Normalize rates.** Raw totals carry population and activity; the claim is
134
+ almost always about intensity. Say which denominator you used and why.
135
+ - **Name the confound.** If a chart could be read two ways, put the alternative
136
+ in the text card and say what rules it out.
137
+ - **End each board with a "So what"** — the decision the board supports.
138
+ - Text card HTML must be single-line (`" ".join(html.split())`) — TipTap mangles
139
+ newlines.
140
+
141
+ **Board titles carry no app prefix.** The project is already the app; a
142
+ `"<App> — "` prefix on every board is noise that eats the readable part of the
143
+ name in the sidebar. Title them `Borough Equity & Access`, not
144
+ `NYC DCP — Borough Equity & Access`.
145
+
146
+ **Always label the legend.** By default a series reads
147
+ `Data Export [Total Events]` — it names the event and the math, not the thing
148
+ being measured. Mixpanel exposes this as **Rename** on a query block; on the
149
+ wire it is `params.sections.show[i].name` plus `userNamed: true` (the flag is
150
+ what stops the UI regenerating the label). Every insights report should pass a
151
+ plain-English label: `Users`, `Exports`, `Comments Filed`.
152
+
153
+ **Merge KPI cards into ONE multi-metric report.** Four big-number cards do not
154
+ need four saved reports. Each report is another entity to create and another
155
+ query to run, and the rate limit is the binding constraint on a full build.
156
+ Concatenate the `sections.show` arrays of several single-metric queries into one
157
+ params dict and keep `chart_type="insights-metric"` — Mixpanel still renders big
158
+ numbers, one per metric. Only safe when the inputs share a time range and
159
+ report-level filter, since `sections.filter` applies to the whole report; per-metric
160
+ filters have to move into `show[i].behavior.filters`.
161
+
162
+ **Share everything, and warm the cohorts.** Two separate failure modes:
163
+
164
+ - *Unshared* — an entity is visible only to its creator. Call
165
+ `/crud/shareDash` (`view_only: false`) and `/crud/shareCohort`
166
+ (`can_edit: true`) for everything you create. There is no share endpoint for
167
+ custom properties, behaviors, or metrics — which is exactly why bearer auth
168
+ matters: get the owner right at creation, because you cannot fix it after.
169
+ - *Unwarmed* — cohort membership computes **lazily**. A freshly created cohort
170
+ reports `count: 0` until something queries it, and a cohort showing 0 members
171
+ reads as broken in a demo. Run one cheap query per cohort after creating it.
172
+ Verify counts, not just existence.
173
+
174
+ ### 6. Verify against the live project
175
+
176
+ `99_verify.py` does two checks:
177
+
178
+ - **structure** — every registered entity still exists.
179
+ - **stories** — re-measure each hook effect live and compare to the knob.
180
+
181
+ Report MATCH / DIRECTIONAL / MISS per story and exit non-zero on any MISS.
182
+ Use a wider tolerance than the dungeon's own ±10% bar (~25%): Mixpanel's cohort
183
+ membership is computed over the whole window, not the dungeon's internal binning,
184
+ so depth-band style cohorts will not line up exactly.
185
+
186
+ ### 7. Report
187
+
188
+ Give the user the board URLs
189
+ (`https://mixpanel.com/project/<pid>/view/<wsid>/app/boards/<id>`), the entity
190
+ counts, and the story verdict table.
191
+
192
+ ## Gotchas that cost real time
193
+
194
+ **Saved cohort ids, never inline definitions.** `Filter.in_cohort(CohortDefinition(...))`
195
+ is accepted and applied, but every definition collapses to the same membership —
196
+ three different cohorts return byte-identical numbers instead of erroring. Create
197
+ cohorts first, then filter by id: `Filter.in_cohort(<saved_id>, "<name>")`.
198
+
199
+ **`create_cohort` via headless 500s** on some projects; `CreateCohortParams.definition`
200
+ also wants `.to_dict()`, not the builder object. Use `/crud/createCohort` — see the
201
+ `powertools` skill for the payload shape and its limits (behavioral counts yes;
202
+ profile-property and behavior-nested property filters no).
203
+
204
+ **Rate limits will kill a full build midway.** A build fires several hundred
205
+ queries; the cap trips after the first couple of boards and the rest fail,
206
+ leaving a half-built project that still looks fine until you count the boards.
207
+ The client must therefore:
208
+
209
+ - **Issue one request at a time**, with a small fixed gap. Parallelism does not
210
+ help and actively hurts — the cap is request-rate based, so concurrency only
211
+ reaches the limit sooner and then every worker sits in backoff together.
212
+ - **Back off hard and genuinely exponentially** on 429: start ~30s, double, cap
213
+ ~15 minutes, ~7 attempts. The cap is per-hour, so short retries just burn
214
+ attempts without letting the window refill. Retry transient 502s the same way.
215
+ - **Be resumable.** Keep the entity registry on disk and reuse by name, so a
216
+ killed build picks up where it stopped instead of duplicating.
217
+
218
+ On Mixpanel-internal projects `--internal` additionally sends the rate-limit
219
+ bypass headers. It is a no-op elsewhere and does *not* remove the need for
220
+ backoff — it supplements it.
221
+
222
+ **Fewer entities is a rate-limit strategy, not just tidiness.** Merging four KPI
223
+ cards into one multi-metric report removes three creates and three queries from
224
+ every build. Prefer one report with N metrics wherever the chart allows it.
225
+
226
+ **`ws._api_client` is lazy** — None immediately after construction. Force it via
227
+ `ws._get_api_client()` before patching request headers.
228
+
229
+ **Idempotency.** Dashboards: delete-by-title then recreate. Everything else:
230
+ look up by name and reuse. Re-running must never duplicate.
231
+
232
+ ## Commands
233
+
234
+ ```bash
235
+ cd dungeons/user/<name>/build
236
+ set -a && . ./.env && set +a
237
+
238
+ uv run --with mixpanel_headless python build_all.py --auth bearer # recommended
239
+ uv run --with mixpanel_headless python build_all.py --auth service # SA-owned assets
240
+ uv run --with mixpanel_headless python build_all.py --internal # + rate-limit bypass
241
+ uv run --with mixpanel_headless python build_all.py --only reset --apply # wipe this build's entities
242
+ uv run --with mixpanel_headless python build_all.py --only dashboards verify
243
+ uv run --with mixpanel_headless python build_all.py --skip-lexicon
244
+ uv run --with mixpanel_headless python build_all.py --from-date 2026-04-18 --to-date 2026-08-17
245
+ uv run --with mixpanel_headless python scripts/99_verify.py # verify only
246
+ ```
247
+
248
+ Phases: `auth`, `reset` (opt-in only), `customprops`, `cohorts`, `lexicon`,
249
+ `entities`, `annotations`, `dashboards`, `verify`.
250
+
251
+ `reset` is never part of the default order — it deletes entities, and only ever
252
+ the ones this build created, matched by name. Use it when assets were created
253
+ under the wrong principal and must be recreated (there is no way to re-own an
254
+ existing custom property).
@@ -45,7 +45,27 @@ node .claude/skills/powertools/snapshot-project.mjs <project_id> --bearer <token
45
45
 
46
46
  GET the path for full docs. Full list: GET `/` and GET `/macro`.
47
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`).
48
+ **crud** — 187 endpoints. GET `/crud` for the full list.
49
+
50
+ *Project lifecycle* (used by create-project's `provision.mjs`): `/crud/createProject`, `/crud/deleteProject` (**irreversible** — deletes the project and all its data; needs `org_id`), `/crud/getProjects`, `/crud/mintServiceAccount`, `/crud/deleteServiceAccount`, `/crud/addGroupKey`, `/crud/deleteGroupKey`, `/crud/setBusinessContext`.
51
+
52
+ *Analysis entities* (used by the `headless-build` skill for what `mixpanel_headless` does not expose): `/crud/createCohort` + `getCohorts`/`getCohort`/`updateCohort`/`deleteCohort`, `/crud/createBehavior` + `getBehaviors`/`deleteBehavior`, `/crud/createMetric` + `getMetrics`/`deleteMetric`, `/crud/createFormula` + `getFormulas`/`deleteFormula`, `/crud/createDash`/`updateDash`/`pinDash`/`duplicateDash`, `/crud/makeInsightsReport`/`makeFunnelsReport`/`makeFlowsReport`/`makeRetentionReport`, `/crud/createCustomProp`, `/crud/createCustomEvent`, `/crud/createAnnotation`, `/crud/createLookupTable`.
53
+
54
+ ### Entity-CRUD response shapes (verified 2026-08-17 — these bite)
55
+
56
+ Three different shapes across one API family. Assuming a flat list or a flat `results.id` silently yields `None`/empty rather than erroring:
57
+
58
+ - **create** returns `{"status":"ok","results":{"<id>":{...}}}` — `results` is keyed BY id, so `results["id"]` is None. Take `next(iter(results.values()))["id"]`.
59
+ - **list** (`getBehaviors`/`getMetrics`/`getFormulas`) returns numerically-keyed entries at the **top level** — `{"0":{...},"1":{...},"duration_ms":…}` — with nothing under `results`. Iterate `raw.values()` and keep dicts that carry a `name`+`id`.
60
+ - `getMetrics` and `getFormulas` return the **same combined set** (metrics and formulas share one store), so dedupe by name if you call both.
61
+
62
+ ### Cohort payload limits (verified on project 4054934)
63
+
64
+ `/crud/createCohort` accepts the native UI shape: `groups[]` of `cohort_group` (each `{type, event:{resourceType:"cohort",value:"$all_users"}, filters[]}`) plus `determiner: "all"|"any"`. What works and what does not:
65
+
66
+ - ✅ behavioral counts — `customProperty.behavior` + `filterOperator: "is at least"|"is at most"`; multiple filters in one group give a RANGE; multiple groups give AND (`all`) / OR (`any`).
67
+ - ❌ profile-property filters, and property filters nested inside a `behavior` — both fail with `Failed to resolve cohort references`. Express those as report `where` filters instead.
68
+ - New cohorts report `count: 0` at creation; membership computes asynchronously.
49
69
 
50
70
  **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
71
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,158 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.6.3 — 2026-08-17
6
+
7
+ ### Fixed
8
+
9
+ - **Cloned events no longer get deduplicated away by Mixpanel on ingest.** All
10
+ eight clone-producing helpers now give each clone a fresh `insert_id`:
11
+ `cloneEvent`, `scaleEventCount`, `injectAfterEvent`, `injectBetween`,
12
+ `injectBurst`, `injectOnNewDays`, `applyLifecycleWave`, and `applyPathBias`.
13
+ Four of them (`cloneEvent` and the three `injectAfterEvent`/`injectBetween`/
14
+ `injectBurst`) copied the template's id verbatim, so clones arrived as
15
+ byte-identical twins and Mixpanel deduped them server-side. The others deleted
16
+ the id, which is no safer: `mixpanel-import` runs with `fixData: true` and
17
+ synthesizes a missing id by content-hashing the record, so identical clones
18
+ collide into one anyway. Measured on an 8K-user dungeon: 608,081 events
19
+ generated, 598,555 landed. The loss fell entirely on the two events the hooks
20
+ clone, and an engineered 4x comment surge read as **1.17x** in the project while
21
+ local story verification still reported 4.14x — verification never inspects
22
+ `insert_id`. After the fix, all 608,081 ids are distinct and in-project counts
23
+ match sent counts exactly. Affects any dungeon whose hooks clone or inject
24
+ events. New shared helpers `stampFreshInsertId` / `cloneWithFreshId` in
25
+ `lib/hook-helpers/_internal.js`; every clone site honors an explicit
26
+ `insert_id` override uniformly. Swept by `tests/unit/clone-insert-id.test.js`,
27
+ which covers every clone site rather than the one that happened to break.
28
+ - **The engine now guarantees unique `insert_id` across each user's final event
29
+ stream.** Fixing the clone helpers was not sufficient: hooks are documented to
30
+ inject events by spreading an existing one, and a spread copies `insert_id`
31
+ too — so any hand-rolled clone reintroduced the bug. Several shipped dungeons
32
+ (including `dungeons/technical/simple.js` and most of `dungeons/vertical/`) do
33
+ exactly that, and measured 160 duplicate ids in a 3,254-event sample. The user
34
+ loop now re-stamps any duplicate or missing `insert_id` after the `everything`
35
+ hook, alongside the existing auto-sort and future-time guards. A legitimate
36
+ stream never carries two events with the same id, so any collision at that
37
+ point is a clone. Pinned by `tests/integration/insert-id-uniqueness.test.js`.
38
+ CLAUDE.md's hook rule now points at `cloneEvent` while documenting that a bare
39
+ spread remains safe.
40
+ - **`mixpanel-import` 3.5.1 → 3.6.1**, which fixes an undercount in the reported
41
+ user-profile success total (3.5.1 reported 196 profiles sent for a batch of
42
+ 6,049 that had in fact all landed).
43
+ - **`create-project` no longer reports a successful group-key add as a failure.**
44
+ The summary read `added [(none)] skipped [(none)]` even when the key was created
45
+ and ready, because the response's `added`/`skipped` arrays are not always
46
+ populated. It now reports against `all_group_keys`, the authoritative post-state,
47
+ and warns when a declared key is genuinely absent.
48
+
49
+ ### Added
50
+
51
+ - **`/headless-build` skill** — final step of the pipeline (after `/create-project`).
52
+ Builds a demoable Mixpanel environment with `mixpanel_headless`: dashboards whose
53
+ narrative text is computed live from the project, Lexicon enrichment, saved
54
+ cohorts, custom properties, behaviors/metrics/formulas, and annotations — then
55
+ re-measures every hook story against the live project and fails on a miss.
56
+ - **`powertools` skill: entity-CRUD index.** Documents the three inconsistent
57
+ response shapes across the create/list endpoints, the `createCohort` payload
58
+ contract, and which cohort filter types the API accepts.
59
+
60
+ ## 1.6.2 — 2026-07-30
61
+
62
+ ### Fixed
63
+
64
+ - **Running the same dungeon twice in one process no longer collapses the second
65
+ run.** `validateDungeonConfig` enriched in place — stamping `isStrictEvent` on
66
+ funnel-step events and `conversionWindowDays` / `_experiment` on funnels — and
67
+ `DUNGEON_MASTER` handed the pipeline a shallow spread, so those stamps landed on
68
+ the caller's own `events` / `funnels` arrays. For a file input that array belongs
69
+ to the ESM module cache, so run 2 got a config already enriched by run 1: every
70
+ event pre-promoted to strict, the catch-all funnel swept nothing, and event volume
71
+ collapsed (measured 317 → 0 on `dungeons/technical/simple.js`). The validator now
72
+ clones its input and enriches only what it returns; functions (`hook`,
73
+ `onProgress`, chance-bound prop thunks) are preserved by reference. Affects an
74
+ object config passed by a caller who reuses it, a file path run more than once in
75
+ a process, and an array of paths. Not affected: raw-text input (each call writes a
76
+ fresh temp module) and `scripts/run-many.mjs` (forks a child per dungeon). Pinned
77
+ by `tests/integration/config-isolation.test.js`.
78
+ - **`verifyDungeon` now applies funnel config to path and JSON inputs.** It read
79
+ `conversionWindowDays` / `order` back off the caller's own `config`, which for a
80
+ string input has no `.funnels` at all — so every funnel check silently ran with
81
+ the default order and an unbounded window. It now reads
82
+ `result.validatedConfig.funnels`.
83
+ - **`verifyDungeon`'s schema report is no longer spurious.** `validateSchema` also
84
+ ran against the raw input: a path input has no fields to derive an expected
85
+ schema from, and a v1.5.1 dungeon keeps `hasAndroidDevices` / `hasBrowser` under
86
+ `switches`, which `deriveExpectedSchema` only sees once flattened. Both produced
87
+ a wall of phantom `flagStamping` findings and a permanently false `report.pass`
88
+ (16 phantom findings on `dungeons/technical/experiments.js`; now 0). It now
89
+ validates against the config the run actually used.
90
+ - **The 22 shipped `dungeons/vertical/*/*.verify.mjs` wrappers now thread VALIDATED
91
+ funnels** into `evaluateStories`. They passed `config.funnels` raw, so no funnel
92
+ story in any vertical had a conversion window — every vertical funnel resolves to
93
+ a 30- or 45-day `conversionWindowDays` that was being dropped. Pre-existing (these
94
+ scripts read shards off disk, so nothing ever enriched their config).
95
+ `validateDungeonConfig` is now exported from `@ak--47/dungeon-master/verify` for
96
+ exactly this.
97
+ - **`verifyDungeon` throws on a multi-dungeon input** instead of silently verifying
98
+ `result[0]` and discarding the rest — which returned a green report for dungeons
99
+ nobody looked at. Call it once per dungeon.
100
+ - **`tests/unit/dungeon-shapes.test.js` no longer asserts the lowercase-hyphen naming
101
+ convention against `dungeons/user/`.** That directory is gitignored per-machine
102
+ scratch space, so the check failed on whatever a given developer had checked out
103
+ locally and was unreproducible in CI. The convention still applies to the tracked
104
+ `technical/` and `vertical/` dungeons.
105
+
106
+ ### Added
107
+
108
+ - **`result.validatedConfig`** — the enriched config the run actually used. Read
109
+ resolved values (`funnels[].conversionWindowDays`, `events[].isStrictEvent`, the
110
+ resolved dataset window) here now that the validator no longer writes them back to
111
+ the object you passed in. Two caveats, both documented on the type: credentials
112
+ are stripped (a Result gets logged), and it is **read-only** — validation is not
113
+ idempotent, so feeding it back into `DUNGEON_MASTER` grows the funnel set and
114
+ eventually yields an empty `sequence`. Re-run the original config instead.
115
+ - **`verifyDungeon(config, checks, overrides)`** — an optional third argument,
116
+ merged into the dungeon before it runs exactly like `DUNGEON_MASTER`'s second
117
+ argument. Lets CI verify a production-scale dungeon at a small `numUsers` /
118
+ `numEvents` without editing it. The report also carries `validatedConfig`.
119
+ - **`validateDungeonConfig`** re-exported from `@ak--47/dungeon-master/verify`, so a
120
+ standalone verify script that reads shards off disk can resolve funnel defaults
121
+ before calling `evaluateStories` / `applyFunnelDefaults`.
122
+ - **`releaseConnections()`** exported from `lib/orchestrators/mixpanel-sender.js`,
123
+ for hosts that drive `mixpanel-import` directly and want the same pool teardown.
124
+
125
+ ### Changed
126
+
127
+ - **`validateDungeonConfig` no longer mutates its input.** Enrichment lands only on
128
+ the returned object. If you called it directly and then read `isStrictEvent` /
129
+ `conversionWindowDays` back off the config you passed in, read the return value
130
+ instead (or `result.validatedConfig` after a run). Running a dungeon is
131
+ unaffected. This is the fix for the collapse bug above, so it ships on a patch.
132
+ - **`mixpanel-import` bumped `^3.3.2` → `^3.5.1`.** Notable for dungeon-master:
133
+ - **Flat events past `epochEnd` no longer kill the entire import job.** The sender
134
+ sets `epochEnd: dayjs().unix()` and dungeon-master events are flat, which on
135
+ 3.3.2 threw `Record has no properties object, cannot fix time` and failed the
136
+ whole batch; 3.5.1 counts the record as `outOfBounds` and carries on. Verified
137
+ directly against both versions.
138
+ - **India-region SCD imports go to `api-in`.** 3.3.2 routed `region: 'IN'` SCD
139
+ batches to `api-eu`.
140
+ - **The library no longer installs five process-global handlers**
141
+ (`unhandledRejection`, `uncaughtException`, `exit`, `SIGINT`, `SIGTERM`) as an
142
+ import side effect. **Hosts embedding dungeon-master will now crash on uncaught
143
+ exceptions and unhandled rejections instead of logging and continuing** — those
144
+ errors were always happening, only the reporting changes. Register your own
145
+ handlers to restore the old behavior. Upside: `user-loop`'s own SIGINT handler is
146
+ no longer preempted, so Ctrl+C cancellation works as designed.
147
+ - Prod-only `npm audit` for the dependency tree: 45 findings → 20 (critical 3 → 1).
148
+ - **`sendToMixpanel` releases mixpanel-import's shared undici connection pools**
149
+ when it settles, so a host that runs occasional imports doesn't hold ingest
150
+ sockets open in between. Runs in a `finally`, guarded, and non-fatal; pools are
151
+ recreated on demand. The pools are process-global, so teardown is refcounted —
152
+ concurrent `DUNGEON_MASTER()` calls in one process won't close sockets out from
153
+ under each other.
154
+ - **`engines.node` raised `>=18.0.0` → `>=20.20.0`**, matching what mixpanel-import
155
+ requires. The old floor had been wrong since 3.3.2 (which already wanted 20.18.1).
156
+
5
157
  ## 1.6.1 — 2026-07-08
6
158
 
7
159
  ### Fixed
@@ -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
  });