@ak--47/dungeon-master 1.6.2 → 1.6.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.
@@ -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,324 @@
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
+ │ ├── 13_render_check.py # saved params must be RENDERABLE, not just queryable
85
+ │ └── 99_verify.py
86
+ └── results/ # entities.json registry, window.json, verification_*.json
87
+ ```
88
+
89
+ ## Steps
90
+
91
+ ### 1. Read the dungeon
92
+
93
+ Pull out, from the dungeon file itself: the `stories` export, the HOOK STORIES
94
+ doc block, the knob constants, the event/property schema, and the VALUE MOMENT.
95
+ **Mirror the knob constants into `_common.py`** so dashboard copy quotes the
96
+ designed value while the charts show the measured one. Never let narrative text
97
+ hardcode a number no longer tied to a knob.
98
+
99
+ ### 2. Probe the project before designing
100
+
101
+ Capabilities differ per project. Run a throwaway probe and design around what
102
+ actually works. Verified on both NYC DCP and Peloton:
103
+
104
+ - Insights `group_by` / `where` / saved-cohort filters: **work**.
105
+ - Funnels `group_by`: **silently does not segment** — returns rows identical to
106
+ ungrouped. Use one funnel per segment with a `where` filter instead.
107
+ - Day-granularity queries reject ranges **over 366 days**. `unit="hour"` works
108
+ fine, including with `group_by` — that is how you read hour-of-day, since the
109
+ App API rejects `hour(A)` in a custom-property formula as an unknown function.
110
+ - Valid `displayOptions.chartType`: `bar`, `column`, `frequency-curve`,
111
+ `funnel-steps`, `funnel-top-paths`, `insights-metric`, `line`, `pie`,
112
+ `retention-curve`, `table`. There is no `stacked-area` — the API rejects it.
113
+
114
+ **Filters and breakdowns fail SILENTLY, returning 0 rather than erroring.** Probe
115
+ every one before a board depends on it. Verified on Square:
116
+
117
+ | Intent | Wrong (returns 0 / "undefined") | Right |
118
+ |---|---|---|
119
+ | Profile property | `Filter.equals("plan", "Pro")` | `Filter.equals("plan", "Pro", resource_type="people")` |
120
+ | Profile property breakdown | `group_by="plan"` | not supported — one query per segment with a user-scoped `where` |
121
+ | Numeric event property | `Filter.equals("depth", 3)` | `Filter.equals("depth", "3")` — as a **string** |
122
+ | Boolean event property | `Filter.equals("flag", True)` | `Filter.is_true("flag")` / `Filter.is_false(...)` |
123
+ | Custom-property breakdown | `group_by="Ticket Band"` | `GroupBy(CustomPropertyRef(<id>))` |
124
+ | Sum of a property | `math="sum"` (raises) | `math="total"` **with** `math_property` |
125
+ | Flows, split by property | `query_flow(where=[Filter.equals(...)])` (raises `Invalid filter type: resourceType`) | cohort filters only — `query_flow(where=[Filter.in_cohort(...)])` |
126
+
127
+ **Querying successfully is NOT evidence that a report renders.** The single most
128
+ expensive bug in the Square build: `Filter.equals(..., resource_type="user")`
129
+ instead of `"people"`. The SDK's type is `Literal["events", "people"]`, but the
130
+ value is not validated, the query endpoint accepts it, and it returns byte-identical
131
+ numbers — so the build printed correct figures, `99_verify.py` re-measured the story
132
+ live and called it a MATCH, `query_saved_report` executed the saved bookmark without
133
+ complaint, and `get_dashboard_erf` reported `is_valid_for_erf: true`. Six dashboard
134
+ cards nonetheless rendered **"The client has issued a malformed request."** The
135
+ defect only existed in what was *persisted*: `sections.filter[0].resourceType`.
136
+
137
+ Two rules follow:
138
+
139
+ - **Never invent an enum value.** If the SDK declares a `Literal`, use one of its
140
+ members even when another string is accepted and works.
141
+ - **Ship a render check.** `scripts/13_render_check.py` in the Square build walks
142
+ every saved bookmark on every registered board and fails on any `resourceType`
143
+ outside `{events, people, user_profiles, cohort}`. It runs as the first step of
144
+ the `verify` phase, before `99_verify`. Inspect persisted params, not query
145
+ results — every query-based check passed while the boards were visibly broken.
146
+
147
+ **Build the new board BEFORE deleting the old one.** The obvious `replace_dashboard`
148
+ (delete by title, then create) means any interruption — and a rate-limit wall
149
+ partway through a board's queries is routine — leaves the project with no board at
150
+ all. Capture the old ids first, create the replacement, then retire the old ones
151
+ (`existing_dashboard_ids` → `publish(..., supersedes=...)` in the Square `_common.py`).
152
+
153
+ **Budget the per-hour query cap across the whole session, not per run.** Probing,
154
+ building, and verifying all draw on the same hourly allowance. Interactive probes
155
+ early in a session can exhaust it and strand a rebuild hours later. Boards with
156
+ many segment-per-query funnels are the expensive ones — the Square activation board
157
+ alone fires ~30.
158
+
159
+ **Retention: cohort maturity will eat your effect.** `query_retention` pools every
160
+ cohort in the range, including ones a fortnight old that cannot yet have failed to
161
+ return in week 4, and there is no server-side way to restrict the cohort window.
162
+ On Square the same attach-retention gap read **1.71x pooled and 2.98x** over cohorts
163
+ with a fully observed horizon. Aggregate the frame yourself: weight buckets by cohort
164
+ size, require each bucket to be observed END TO END (`(b+1)*unit - 1` days of history,
165
+ not `b*unit`), and cap the cohort date. Put both numbers on the board — the diluted
166
+ one is what the chart shows, and naming why is a better demo than hiding it.
167
+
168
+ **Time-to-convert is a MEAN, and means are tail-dominated.** Funnel frames carry
169
+ `avg_time` and `avg_time_from_start` (seconds; not monotonic across steps — each is
170
+ over that step's own survivors). A dungeon knob expressed as a median ratio will not
171
+ reproduce: Square's designed 13x median gap measured 1.95x as a mean over a 30-day
172
+ window. Use the **speed curve** instead — run the same funnel at 1/3/7/14-day
173
+ conversion windows and read what share of each segment's eventual conversions had
174
+ landed by then. Same effect, expressed in a statistic Mixpanel actually computes.
175
+
176
+ ### 3. Auto-detect the data window
177
+
178
+ Never hardcode dates. A regenerated dungeon lands on new ones and every chart
179
+ silently clips. Probe wide at `unit="month"`, then resolve exact days inside the
180
+ non-empty months (day charts cap at 366 days). Cache to `results/window.json`.
181
+
182
+ ### 4. Build entities, then boards
183
+
184
+ Order matters: custom properties and cohorts first (boards break down by them),
185
+ Lexicon, behaviors/metrics/formulas, annotations, then dashboards, with the
186
+ "Start Here" board last so it can link the others by id.
187
+
188
+ Keep a `results/entities.json` registry of every created id — the Start Here
189
+ board and the verifier both read it.
190
+
191
+ ### 5. Write boards that argue, not just render
192
+
193
+ The difference between a dashboard and a demo:
194
+
195
+ - **Query first, then narrate.** Compute the numbers live, then interpolate them
196
+ into text cards. Never write a number by hand.
197
+ - **One board per story**, plus a Start Here tour board.
198
+ - **Normalize rates.** Raw totals carry population and activity; the claim is
199
+ almost always about intensity. Say which denominator you used and why.
200
+ - **Name the confound.** If a chart could be read two ways, put the alternative
201
+ in the text card and say what rules it out.
202
+ - **End each board with a "So what"** — the decision the board supports.
203
+ - Text card HTML must be single-line (`" ".join(html.split())`) — TipTap mangles
204
+ newlines.
205
+
206
+ **Board titles carry no app prefix.** The project is already the app; a
207
+ `"<App> — "` prefix on every board is noise that eats the readable part of the
208
+ name in the sidebar. Title them `Borough Equity & Access`, not
209
+ `NYC DCP — Borough Equity & Access`.
210
+
211
+ **Always label the legend.** By default a series reads
212
+ `Data Export [Total Events]` — it names the event and the math, not the thing
213
+ being measured. Mixpanel exposes this as **Rename** on a query block; on the
214
+ wire it is `params.sections.show[i].name` plus `userNamed: true` (the flag is
215
+ what stops the UI regenerating the label). Every insights report should pass a
216
+ plain-English label: `Users`, `Exports`, `Comments Filed`.
217
+
218
+ **Merge KPI cards into ONE multi-metric report.** Four big-number cards do not
219
+ need four saved reports. Each report is another entity to create and another
220
+ query to run, and the rate limit is the binding constraint on a full build.
221
+ Concatenate the `sections.show` arrays of several single-metric queries into one
222
+ params dict and keep `chart_type="insights-metric"` — Mixpanel still renders big
223
+ numbers, one per metric. Only safe when the inputs share a time range and
224
+ report-level filter, since `sections.filter` applies to the whole report; per-metric
225
+ filters have to move into `show[i].behavior.filters`.
226
+
227
+ **Share everything, and warm the cohorts.** Two separate failure modes:
228
+
229
+ - *Unshared* — an entity is visible only to its creator. Call
230
+ `/crud/shareDash` (`view_only: false`) and `/crud/shareCohort`
231
+ (`can_edit: true`) for everything you create. There is no share endpoint for
232
+ custom properties, behaviors, or metrics — which is exactly why bearer auth
233
+ matters: get the owner right at creation, because you cannot fix it after.
234
+ - *Unwarmed* — cohort membership computes **lazily**. A freshly created cohort
235
+ reports `count: 0` until something queries it, and a cohort showing 0 members
236
+ reads as broken in a demo. Run one cheap query per cohort after creating it.
237
+ Verify counts, not just existence.
238
+
239
+ ### 6. Verify against the live project
240
+
241
+ `99_verify.py` does two checks:
242
+
243
+ - **structure** — every registered entity still exists.
244
+ - **stories** — re-measure each hook effect live and compare to the knob.
245
+
246
+ Report MATCH / DIRECTIONAL / MISS per story and exit non-zero on any MISS.
247
+ Use a wider tolerance than the dungeon's own ±10% bar (~25%): Mixpanel's cohort
248
+ membership is computed over the whole window, not the dungeon's internal binning,
249
+ so depth-band style cohorts will not line up exactly.
250
+
251
+ ### 7. Report
252
+
253
+ Give the user the board URLs
254
+ (`https://mixpanel.com/project/<pid>/view/<wsid>/app/boards/<id>`), the entity
255
+ counts, and the story verdict table.
256
+
257
+ ## Gotchas that cost real time
258
+
259
+ **Saved cohort ids, never inline definitions.** `Filter.in_cohort(CohortDefinition(...))`
260
+ is accepted and applied, but every definition collapses to the same membership —
261
+ three different cohorts return byte-identical numbers instead of erroring. Create
262
+ cohorts first, then filter by id: `Filter.in_cohort(<saved_id>, "<name>")`.
263
+
264
+ **`getCohorts` can report `count: 0` for every cohort** even when they hold thousands
265
+ of members and filter queries correctly. Do not use the listing's count as the
266
+ membership check in `99_verify.py` — run one cheap query per cohort instead. This is
267
+ the same call that warms them, so it costs nothing extra.
268
+
269
+ **`create_cohort` via headless 500s** on some projects; `CreateCohortParams.definition`
270
+ also wants `.to_dict()`, not the builder object. Use `/crud/createCohort` — see the
271
+ `powertools` skill for the payload shape and its limits (behavioral counts yes;
272
+ profile-property and behavior-nested property filters no).
273
+
274
+ **Rate limits will kill a full build midway.** A build fires several hundred
275
+ queries; the cap trips after the first couple of boards and the rest fail,
276
+ leaving a half-built project that still looks fine until you count the boards.
277
+ The client must therefore:
278
+
279
+ - **Issue one request at a time**, with a small fixed gap. Parallelism does not
280
+ help and actively hurts — the cap is request-rate based, so concurrency only
281
+ reaches the limit sooner and then every worker sits in backoff together.
282
+ - **Back off hard and genuinely exponentially** on 429: start ~30s, double, cap
283
+ ~15 minutes, ~7 attempts. The cap is per-hour, so short retries just burn
284
+ attempts without letting the window refill. Retry transient 502s the same way.
285
+ - **Be resumable.** Keep the entity registry on disk and reuse by name, so a
286
+ killed build picks up where it stopped instead of duplicating.
287
+
288
+ On Mixpanel-internal projects `--internal` additionally sends the rate-limit
289
+ bypass headers. It is a no-op elsewhere and does *not* remove the need for
290
+ backoff — it supplements it.
291
+
292
+ **Fewer entities is a rate-limit strategy, not just tidiness.** Merging four KPI
293
+ cards into one multi-metric report removes three creates and three queries from
294
+ every build. Prefer one report with N metrics wherever the chart allows it.
295
+
296
+ **`ws._api_client` is lazy** — None immediately after construction. Force it via
297
+ `ws._get_api_client()` before patching request headers.
298
+
299
+ **Idempotency.** Dashboards: delete-by-title then recreate. Everything else:
300
+ look up by name and reuse. Re-running must never duplicate.
301
+
302
+ ## Commands
303
+
304
+ ```bash
305
+ cd dungeons/user/<name>/build
306
+ set -a && . ./.env && set +a
307
+
308
+ uv run --with mixpanel_headless python build_all.py --auth bearer # recommended
309
+ uv run --with mixpanel_headless python build_all.py --auth service # SA-owned assets
310
+ uv run --with mixpanel_headless python build_all.py --internal # + rate-limit bypass
311
+ uv run --with mixpanel_headless python build_all.py --only reset --apply # wipe this build's entities
312
+ uv run --with mixpanel_headless python build_all.py --only dashboards verify
313
+ uv run --with mixpanel_headless python build_all.py --skip-lexicon
314
+ uv run --with mixpanel_headless python build_all.py --from-date 2026-04-18 --to-date 2026-08-17
315
+ uv run --with mixpanel_headless python scripts/99_verify.py # verify only
316
+ ```
317
+
318
+ Phases: `auth`, `reset` (opt-in only), `customprops`, `cohorts`, `lexicon`,
319
+ `entities`, `annotations`, `dashboards`, `verify`.
320
+
321
+ `reset` is never part of the default order — it deletes entities, and only ever
322
+ the ones this build created, matched by name. Use it when assets were created
323
+ under the wrong principal and must be recreated (there is no way to re-own an
324
+ 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,176 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.6.4 — 2026-09-01
6
+
7
+ Answers the doc/type half of the DM4 v5 engine request
8
+ (`dungeon-master-library-changes-requested.md`, 2026-09-01). Every claim in that
9
+ document was checked against source; all were accurate except the three noted
10
+ under "Already correct" below.
11
+
12
+ This release is deliberately scoped to changes that cannot alter generated data:
13
+ docs, types, one additive helper option, one additive config form, and one
14
+ warning. The feature requests are tracked for 1.7.0 — see "Deferred to 1.7.0".
15
+
16
+ No generated output changes. Verified: same seed, same config, `concurrency: 1`,
17
+ pinned dataset window — 1.6.4 and 1.6.3 produce byte-identical events, user
18
+ profiles, and group profiles.
19
+
20
+ ### Added
21
+
22
+ - **`scaleEventCount(events, name, factor, { spreadDays })`.** Scatters clones
23
+ uniformly across the next N days instead of stepping 1 second at a time. The
24
+ default 1-second spread cannot create a new distinct active day and cannot open
25
+ a new session under Mixpanel's 30-minute gap rule, so a default call moves event
26
+ volume and nothing else — not active days, DAU, stickiness, sessions, frequency
27
+ bins, or retention. 33 dungeons in the DM4 corpus used the default and then
28
+ documented an active-day, session, frequency, or stickiness claim. All 33 were
29
+ wrong. The limitation is now stated in the helper's JSDoc, in HOOKS.md §2.1, and
30
+ in HOOKS.md gotcha #22. Clones that land past `FIXED_NOW` are still dropped by
31
+ the future-time guard. Default behavior is unchanged.
32
+ - **Named-object form for `groupKeys`.** `{ key, cardinality, events? }` alongside
33
+ the positional tuple `[key, cardinality]` / `[key, cardinality, events]`. Both
34
+ forms may be mixed in one array. The validator normalizes to tuples, so hooks,
35
+ the generators, and the verifier still see exactly one shape. Added so a
36
+ form-driven config generator never has to emit an untyped positional tuple.
37
+ Bad input throws with the offending index.
38
+ - **HOOKS.md §2.1.1 — "Cloned events MUST carry a fresh `insert_id`."** The
39
+ consequence was only in a source comment before. Four DM4 corpus files
40
+ hand-rolled `JSON.parse(JSON.stringify(e))` and had their entire engineered
41
+ surge deduped away by Mixpanel at ingest, with local verification still
42
+ reporting the surge as present. Includes the note that data generated this way
43
+ on 1.6.2 or earlier is wrong in-project and must be regenerated.
44
+ - **README "one config surface, not two."** States that the `credentials` /
45
+ `switches` / `identity` sub-objects are the canonical form, that the flat
46
+ top-level keys are a back-compat alias, and that top-level wins when both are
47
+ set. Mirrored in CLAUDE.md and in the three sub-object JSDoc blocks.
48
+
49
+ ### Changed
50
+
51
+ - **The `avgActiveDaysPerUser` + `engagementDecay` warning is no longer gated
52
+ behind `verbose`.** The combination silently returns an active-day count at or
53
+ below the configured value, never above. A config UI that shows the requested
54
+ number was lying about it. Documented in HOOKS.md §2.5 and in the
55
+ `avgActiveDaysPerUser` JSDoc.
56
+ - **`retentionCurve` precedence is now documented.** When both `retentionCurve`
57
+ and `avgActiveDaysPerUser` are set, the curve wins and `avgActiveDaysPerUser` is
58
+ ignored entirely. Behavior is unchanged — only the docs were missing. Added to
59
+ the CLAUDE.md safe-range table, the README config table, HOOKS.md §2.5, and the
60
+ `avgActiveDaysPerUser` JSDoc.
61
+
62
+ ### Fixed (types and docs)
63
+
64
+ - **`writeToDisk` doc was wrong.** It read "If true (default), writes output files
65
+ to ./data/". The runtime default is `false` (`config-validator.js`).
66
+ - **`region` types disagreed.** Top-level `region` was `"US" | "EU"` while
67
+ `DungeonCredentials.region` allowed `"IN"`. Both now use a shared `Region` type
68
+ of `'US' | 'EU' | 'IN'`, which matches what `mixpanel-import` accepts.
69
+ - **`hasAttributionFlags` was presented as a settable switch.** The validator
70
+ unconditionally overwrites it with `events.some(e => e.isAttributionEvent)`. It
71
+ is removed from `DungeonSwitches` and from the `switches` hoisting allowlist,
72
+ and marked `@internal` on `Dungeon`. Read it off `result.validatedConfig`;
73
+ setting it never did anything.
74
+ - **`GroupEventConfig` / `config.groupEvents` removed from the public types.** A
75
+ declared-only stub — nothing in `lib/` ever read it, its own doc comment said
76
+ "not yet implemented", and no shipped dungeon set it. Removed from `types.d.ts`,
77
+ `lib/templates/abbreviated.d.ts`, `lib/templates/schema.d.ts`, and the `wrapFunc`
78
+ whitelist. To scope an event to a group, list it in that group key's `events`
79
+ array.
80
+
81
+ - **The determinism claim was overstated.** README and CLAUDE.md both said "same
82
+ seed + same config + `concurrency: 1` = byte-identical output". That has been
83
+ false since 1.4.0: `insert_id` is a `randomUUID()`, so it differs on every run
84
+ by design — which is what keeps Mixpanel from deduping a re-import of the same
85
+ dataset. Found while verifying that this release changes no output. Everything
86
+ else is byte-identical; strip `insert_id` before diffing two runs. Both docs now
87
+ say so.
88
+
89
+ ### Already correct (no change needed)
90
+
91
+ - `funnels[].reentry` and `funnels[].stepFilters` were reported as reading like
92
+ generation features. Their JSDoc already says "Verifier-only hint … Generator
93
+ behavior unchanged."
94
+ - The dead persona fields (`churnRate`, `activeWindow`, `soupOverride`) were
95
+ reported as `verbose`-gated. Their warning already fires unconditionally, once
96
+ per process. They stay accepted and warned; removing them from the `Persona`
97
+ type is a 1.7.0 change because it is a type-level break.
98
+
99
+ ### Deferred to 1.7.0
100
+
101
+ Everything below is new public surface or a behavior change, so none of it belongs
102
+ in a patch. Per-item design lives in the maintainer's local `plans/1.7.0/SPEC.md`
103
+ (the `plans/` tree is not published).
104
+
105
+ | Request | Why not in 1.6.4 |
106
+ |---|---|
107
+ | `funnels[].conditions` operators (`in`, `gte`, `neq`, …) | New public surface. Also throws on function/array condition values, which is a break. |
108
+ | Experiment variant stamped on the user profile | New profile property; needs a change to when variants resolve. |
109
+ | `(ctx) => value` for property value functions | New signature. Requires an arity guard on the `choose` source-string cache first, or context-aware functions get frozen at their first evaluation. |
110
+ | `stickyEventProps` | New surface. Also needs `lib/verify/schema-validator.js` taught about it, or `/verify-dungeon` reports every sticky prop as flag stamping. |
111
+ | Stable per-user `location` under `hasLocation` | A real fix (`featureCtx.userLocation` is computed and never read), but it changes generated event geo. |
112
+ | `personas[].ttcModifier` | New surface. |
113
+ | Removing the three dead persona fields from the `Persona` type | Type-level break. |
114
+ | `campaignPerUser` | New surface. |
115
+ | `autoPowerLaw: false` and `{ __weights }` | New surface. |
116
+ | `result.warnings[]` for clamps | New result surface. |
117
+ | Ad spend derived from users acquired per campaign | Needs a new cross-user aggregate pass. Deferred past 1.7.0. |
118
+ | Engine-side `conversionRate` saturation reporting | Depends on `result.warnings[]`. Note: the engine can report its own `Math.min(100, …)` clamps, but not a hook's own `Math.min(95, rate * 3)` — that cap belongs to the hook and the engine never sees the intended value. |
119
+
120
+ ## 1.6.3 — 2026-08-17
121
+
122
+ ### Fixed
123
+
124
+ - **Cloned events no longer get deduplicated away by Mixpanel on ingest.** All
125
+ eight clone-producing helpers now give each clone a fresh `insert_id`:
126
+ `cloneEvent`, `scaleEventCount`, `injectAfterEvent`, `injectBetween`,
127
+ `injectBurst`, `injectOnNewDays`, `applyLifecycleWave`, and `applyPathBias`.
128
+ Four of them (`cloneEvent` and the three `injectAfterEvent`/`injectBetween`/
129
+ `injectBurst`) copied the template's id verbatim, so clones arrived as
130
+ byte-identical twins and Mixpanel deduped them server-side. The others deleted
131
+ the id, which is no safer: `mixpanel-import` runs with `fixData: true` and
132
+ synthesizes a missing id by content-hashing the record, so identical clones
133
+ collide into one anyway. Measured on an 8K-user dungeon: 608,081 events
134
+ generated, 598,555 landed. The loss fell entirely on the two events the hooks
135
+ clone, and an engineered 4x comment surge read as **1.17x** in the project while
136
+ local story verification still reported 4.14x — verification never inspects
137
+ `insert_id`. After the fix, all 608,081 ids are distinct and in-project counts
138
+ match sent counts exactly. Affects any dungeon whose hooks clone or inject
139
+ events. New shared helpers `stampFreshInsertId` / `cloneWithFreshId` in
140
+ `lib/hook-helpers/_internal.js`; every clone site honors an explicit
141
+ `insert_id` override uniformly. Swept by `tests/unit/clone-insert-id.test.js`,
142
+ which covers every clone site rather than the one that happened to break.
143
+ - **The engine now guarantees unique `insert_id` across each user's final event
144
+ stream.** Fixing the clone helpers was not sufficient: hooks are documented to
145
+ inject events by spreading an existing one, and a spread copies `insert_id`
146
+ too — so any hand-rolled clone reintroduced the bug. Several shipped dungeons
147
+ (including `dungeons/technical/simple.js` and most of `dungeons/vertical/`) do
148
+ exactly that, and measured 160 duplicate ids in a 3,254-event sample. The user
149
+ loop now re-stamps any duplicate or missing `insert_id` after the `everything`
150
+ hook, alongside the existing auto-sort and future-time guards. A legitimate
151
+ stream never carries two events with the same id, so any collision at that
152
+ point is a clone. Pinned by `tests/integration/insert-id-uniqueness.test.js`.
153
+ CLAUDE.md's hook rule now points at `cloneEvent` while documenting that a bare
154
+ spread remains safe.
155
+ - **`mixpanel-import` 3.5.1 → 3.6.1**, which fixes an undercount in the reported
156
+ user-profile success total (3.5.1 reported 196 profiles sent for a batch of
157
+ 6,049 that had in fact all landed).
158
+ - **`create-project` no longer reports a successful group-key add as a failure.**
159
+ The summary read `added [(none)] skipped [(none)]` even when the key was created
160
+ and ready, because the response's `added`/`skipped` arrays are not always
161
+ populated. It now reports against `all_group_keys`, the authoritative post-state,
162
+ and warns when a declared key is genuinely absent.
163
+
164
+ ### Added
165
+
166
+ - **`/headless-build` skill** — final step of the pipeline (after `/create-project`).
167
+ Builds a demoable Mixpanel environment with `mixpanel_headless`: dashboards whose
168
+ narrative text is computed live from the project, Lexicon enrichment, saved
169
+ cohorts, custom properties, behaviors/metrics/formulas, and annotations — then
170
+ re-measures every hook story against the live project and fails on a miss.
171
+ - **`powertools` skill: entity-CRUD index.** Documents the three inconsistent
172
+ response shapes across the create/list endpoints, the `createCohort` payload
173
+ contract, and which cohort filter types the API accepts.
174
+
5
175
  ## 1.6.2 — 2026-07-30
6
176
 
7
177
  ### Fixed