@ak--47/dungeon-master 1.6.2 → 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.
- package/.claude/skills/create-project/provision.mjs +16 -3
- package/.claude/skills/headless-build/SKILL.md +254 -0
- package/.claude/skills/powertools/SKILL.md +21 -1
- package/CHANGELOG.md +55 -0
- package/lib/hook-helpers/_internal.js +45 -0
- package/lib/hook-helpers/inject.js +7 -7
- package/lib/hook-helpers/mutate.js +8 -6
- package/lib/hook-helpers/shape.js +4 -4
- package/lib/hook-patterns/frequency-by-frequency.js +2 -2
- package/lib/orchestrators/user-loop.js +27 -0
- package/package.json +2 -2
|
@@ -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)
|
|
319
|
-
const skipped = (groupKeyResult?.skipped || []).map(fmt).filter(Boolean)
|
|
320
|
-
|
|
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** —
|
|
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,61 @@
|
|
|
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
|
+
|
|
5
60
|
## 1.6.2 — 2026-07-30
|
|
6
61
|
|
|
7
62
|
### Fixed
|
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Give a cloned event its own `insert_id`.
|
|
5
|
+
*
|
|
6
|
+
* Clones MUST NOT inherit their template's id, and stripping the id is not
|
|
7
|
+
* enough either: mixpanel-import runs with `fixData: true`, which synthesizes a
|
|
8
|
+
* missing id by content-hashing the record — so two clones that differ only in
|
|
9
|
+
* ways the hash ignores collapse into one on ingest. Mixpanel then dedupes them
|
|
10
|
+
* server-side and the engineered volume silently disappears from the project
|
|
11
|
+
* while local verification (which never looks at `insert_id`) still passes.
|
|
12
|
+
*
|
|
13
|
+
* `insert_id` is already assigned via `randomUUID()` in the event generator
|
|
14
|
+
* (`lib/generators/events.js`), so it sits outside the seeded
|
|
15
|
+
* byte-identical-output guarantee; using it here keeps clone ids consistent with
|
|
16
|
+
* engine-generated ones.
|
|
17
|
+
*
|
|
18
|
+
* @template {{insert_id?: string}} T
|
|
19
|
+
* @param {T} clone
|
|
20
|
+
* @returns {T} the same object, with a fresh `insert_id`
|
|
21
|
+
*/
|
|
22
|
+
export function stampFreshInsertId(clone) {
|
|
23
|
+
clone.insert_id = randomUUID();
|
|
24
|
+
return clone;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spread a template into a new event, merge `overrides`, and give it a fresh
|
|
29
|
+
* `insert_id` — unless the caller pinned one explicitly.
|
|
30
|
+
*
|
|
31
|
+
* Every clone site should go through this so the override contract is uniform:
|
|
32
|
+
* a caller that passes `insert_id` in `overrides` keeps it, and everyone else
|
|
33
|
+
* gets a unique id rather than a duplicate of the template's.
|
|
34
|
+
*
|
|
35
|
+
* @template {Record<string, any>} T
|
|
36
|
+
* @param {T} template - Event to clone from.
|
|
37
|
+
* @param {Record<string, any>} [overrides] - Fields to merge on top.
|
|
38
|
+
* @returns {T} the new event
|
|
39
|
+
*/
|
|
40
|
+
export function cloneWithFreshId(template, overrides = {}) {
|
|
41
|
+
const clone = /** @type {T} */ ({ ...template, ...overrides });
|
|
42
|
+
if (!(overrides && 'insert_id' in overrides)) stampFreshInsertId(clone);
|
|
43
|
+
return clone;
|
|
44
|
+
}
|
|
45
|
+
|
|
1
46
|
export function toMs(t) {
|
|
2
47
|
if (typeof t === 'number') return t > 1e12 ? t : t > 1e9 ? t * 1000 : t;
|
|
3
48
|
return Date.parse(t);
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { getChance } from '../utils/utils.js';
|
|
13
|
-
import { toMs, writeTime } from './_internal.js';
|
|
13
|
+
import { toMs, writeTime, stampFreshInsertId, cloneWithFreshId } from './_internal.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Splice a cloned event into `events` immediately after `sourceEvent`. Time is
|
|
@@ -29,7 +29,7 @@ export function injectAfterEvent(events, sourceEvent, templateEvent, gapMs, over
|
|
|
29
29
|
if (!events || !sourceEvent || !templateEvent) return null;
|
|
30
30
|
const baseT = toMs(sourceEvent.time);
|
|
31
31
|
if (!Number.isFinite(baseT)) return null;
|
|
32
|
-
const newEv =
|
|
32
|
+
const newEv = cloneWithFreshId(templateEvent, overrides);
|
|
33
33
|
writeTime(newEv, baseT + gapMs);
|
|
34
34
|
const idx = events.indexOf(sourceEvent);
|
|
35
35
|
if (idx >= 0) events.splice(idx + 1, 0, newEv);
|
|
@@ -60,7 +60,7 @@ export function injectBetween(events, eventA, eventB, templateEvent, overrides =
|
|
|
60
60
|
const aT = toMs(a.time);
|
|
61
61
|
const bT = toMs(b.time);
|
|
62
62
|
if (!Number.isFinite(aT) || !Number.isFinite(bT)) return null;
|
|
63
|
-
const newEv =
|
|
63
|
+
const newEv = cloneWithFreshId(templateEvent, overrides);
|
|
64
64
|
writeTime(newEv, (aT + bT) / 2);
|
|
65
65
|
const bIdxOrig = events.indexOf(b);
|
|
66
66
|
if (bIdxOrig >= 0) events.splice(bIdxOrig, 0, newEv);
|
|
@@ -89,7 +89,7 @@ export function injectBurst(events, templateEvent, count, anchorTime, spreadMs,
|
|
|
89
89
|
const created = [];
|
|
90
90
|
for (let i = 0; i < count; i++) {
|
|
91
91
|
const offset = chance.floating({ min: -spreadMs, max: spreadMs });
|
|
92
|
-
const newEv =
|
|
92
|
+
const newEv = cloneWithFreshId(templateEvent, overrides);
|
|
93
93
|
writeTime(newEv, anchorMs + offset);
|
|
94
94
|
events.push(newEv);
|
|
95
95
|
created.push(newEv);
|
|
@@ -119,7 +119,8 @@ const DAY_MS = 86400000;
|
|
|
119
119
|
* 5. Find a template event of `eventName`; if none exist for this user,
|
|
120
120
|
* return unchanged (we honor schema-first: don't fabricate events).
|
|
121
121
|
* 6. Clone the template onto each picked day at a random hour within the
|
|
122
|
-
* day. `insert_id`
|
|
122
|
+
* day. Each clone gets a FRESH `insert_id` (inheriting or omitting it lets
|
|
123
|
+
* Mixpanel dedupe the clones away on ingest).
|
|
123
124
|
* 7. Append clones to the array. Caller's downstream sort handles ordering.
|
|
124
125
|
*
|
|
125
126
|
* @param {Object[]} events - Full user event array (from `everything` hook).
|
|
@@ -183,9 +184,8 @@ export function injectOnNewDays(events, eventName, targetDays, options = {}) {
|
|
|
183
184
|
const lo = Math.max(dayStart, minMs);
|
|
184
185
|
const hi = Math.min(dayEnd, maxMs);
|
|
185
186
|
const newMs = lo >= hi ? lo : chance.integer({ min: lo, max: hi });
|
|
186
|
-
const clone =
|
|
187
|
+
const clone = cloneWithFreshId(template, overrides);
|
|
187
188
|
writeTime(clone, newMs);
|
|
188
|
-
delete clone.insert_id;
|
|
189
189
|
events.push(clone);
|
|
190
190
|
}
|
|
191
191
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `Math.random()` — so dungeon runs stay reproducible.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { toMs } from './_internal.js';
|
|
11
|
+
import { toMs, stampFreshInsertId, cloneWithFreshId } from './_internal.js';
|
|
12
12
|
|
|
13
13
|
import { getChance } from '../utils/utils.js';
|
|
14
14
|
|
|
@@ -25,7 +25,9 @@ import { getChance } from '../utils/utils.js';
|
|
|
25
25
|
*/
|
|
26
26
|
export function cloneEvent(template, overrides = {}) {
|
|
27
27
|
if (!template) throw new Error('cloneEvent: template is required');
|
|
28
|
-
|
|
28
|
+
// A clone that keeps its template's insert_id is deduped away by Mixpanel on
|
|
29
|
+
// ingest. Callers may still pin one explicitly via overrides.
|
|
30
|
+
return /** @type {T} */ (cloneWithFreshId(template, overrides));
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -57,8 +59,9 @@ export function dropEventsWhere(events, predicate) {
|
|
|
57
59
|
* integer = -dropped.
|
|
58
60
|
* - factor === 1 or no matches: no-op, returns 0.
|
|
59
61
|
*
|
|
60
|
-
* Note:
|
|
61
|
-
*
|
|
62
|
+
* Note: cloned events get a FRESH `insert_id` — they must not inherit the
|
|
63
|
+
* source's, and leaving it blank is not safe either (the importer content-hashes
|
|
64
|
+
* missing ids, so identical clones collide and Mixpanel dedupes them away).
|
|
62
65
|
*
|
|
63
66
|
* @param {Array<{event:string,time:string|number,insert_id?:string}>} events
|
|
64
67
|
* @param {string} eventName
|
|
@@ -78,8 +81,7 @@ export function scaleEventCount(events, eventName, factor) {
|
|
|
78
81
|
const newTime = Number.isFinite(baseMs)
|
|
79
82
|
? new Date(baseMs + (i + 1) * 1000).toISOString()
|
|
80
83
|
: src.time;
|
|
81
|
-
const clone = { ...src, time: newTime };
|
|
82
|
-
delete clone.insert_id;
|
|
84
|
+
const clone = stampFreshInsertId({ ...src, time: newTime });
|
|
83
85
|
events.push(clone);
|
|
84
86
|
added++;
|
|
85
87
|
}
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* (HOOKS.md §2.16), a biased Flows path branch (§2.17), and a deterministic
|
|
7
7
|
* session cadence (§2.13). All three are `everything`-hook-only — they need
|
|
8
8
|
* the whole stream — and obey the schema-first rules: clones only (spread
|
|
9
|
-
* from the user's own events, `insert_id`
|
|
9
|
+
* from the user's own events, each given a fresh `insert_id`), no fabricated events,
|
|
10
10
|
* seeded `chance` for all randomness. Timestamp rewrites are safe because
|
|
11
11
|
* the engine re-derives `session_id` on the final event set (v1.6 P2.1).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { getChance } from '../utils/utils.js';
|
|
15
|
-
import { toMs, writeTime } from './_internal.js';
|
|
15
|
+
import { toMs, writeTime, stampFreshInsertId } from './_internal.js';
|
|
16
16
|
import { hashFloat } from './cohort.js';
|
|
17
17
|
|
|
18
18
|
const DAY_MS = 86400000;
|
|
@@ -100,7 +100,7 @@ export function applyLifecycleWave(events, uid, opts) {
|
|
|
100
100
|
for (let i = 0; i < resurrectBurst; i++) {
|
|
101
101
|
const clone = { ...template };
|
|
102
102
|
writeTime(clone, t);
|
|
103
|
-
|
|
103
|
+
stampFreshInsertId(clone);
|
|
104
104
|
if (!clone.user_id && uid) clone.user_id = uid;
|
|
105
105
|
kept.push(clone);
|
|
106
106
|
t += chance.integer({ min: MIN_MS, max: 10 * MIN_MS });
|
|
@@ -168,7 +168,7 @@ export function applyPathBias(events, uid, opts) {
|
|
|
168
168
|
t += chance.integer({ min: lo, max: hi }) * 1000;
|
|
169
169
|
const clone = { ...tpl };
|
|
170
170
|
writeTime(clone, t);
|
|
171
|
-
|
|
171
|
+
stampFreshInsertId(clone);
|
|
172
172
|
if (!clone.user_id && uid) clone.user_id = uid;
|
|
173
173
|
events.push(clone);
|
|
174
174
|
}
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* - Operates on the user's full event stream — call from the `everything` hook.
|
|
17
17
|
* - Does NOT add new properties; uses existing event names defined in the dungeon
|
|
18
18
|
* schema.
|
|
19
|
-
* - Cloned events
|
|
20
|
-
*
|
|
19
|
+
* - Cloned events get a fresh `insert_id` (mutate.scaleEventCount handles that),
|
|
20
|
+
* so Mixpanel does not dedupe them away on ingest.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { binUsersByEventCount } from '../hook-helpers/cohort.js';
|
|
@@ -750,6 +750,33 @@ export async function userLoop(context) {
|
|
|
750
750
|
});
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
// v1.6.3: guarantee unique `insert_id` across the user's final stream.
|
|
754
|
+
//
|
|
755
|
+
// Hooks clone events by spreading an existing one — the documented way
|
|
756
|
+
// to inject — and a spread copies `insert_id` along with everything
|
|
757
|
+
// else. Mixpanel deduplicates on `insert_id` at ingest, so those clones
|
|
758
|
+
// are accepted, reported as successful, and then silently dropped: the
|
|
759
|
+
// engineered volume never appears in the project. Nothing downstream
|
|
760
|
+
// catches it, because local verification never inspects `insert_id`.
|
|
761
|
+
//
|
|
762
|
+
// The hook-helper clone atoms stamp fresh ids themselves, but hooks are
|
|
763
|
+
// free to hand-roll a spread (and many shipped dungeons do), so the
|
|
764
|
+
// only reliable place to enforce this is here, over the finished set.
|
|
765
|
+
// A legitimate stream never contains two events with the same id, so
|
|
766
|
+
// any collision at this point is a clone that needs its own id.
|
|
767
|
+
// `> 0`, not `> 1`: a lone event cannot collide, but it can still be
|
|
768
|
+
// missing an id if a hook replaced it with a constructed object.
|
|
769
|
+
if (usersEvents.length > 0) {
|
|
770
|
+
const seenInsertIds = new Set();
|
|
771
|
+
for (const ev of usersEvents) {
|
|
772
|
+
if (!ev) continue;
|
|
773
|
+
if (!ev.insert_id || seenInsertIds.has(ev.insert_id)) {
|
|
774
|
+
ev.insert_id = randomUUID();
|
|
775
|
+
}
|
|
776
|
+
seenInsertIds.add(ev.insert_id);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
753
780
|
// Defensive guard: drop any events whose timestamp landed past the
|
|
754
781
|
// configured dataset end. Hooks that duplicate events with time offsets
|
|
755
782
|
// (weekend surges, viral spreads) can leak a few past the boundary.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ak--47/dungeon-master",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"dotenv": "^16.4.5",
|
|
88
88
|
"hyparquet-writer": "^0.6.1",
|
|
89
89
|
"mixpanel": "^0.18.0",
|
|
90
|
-
"mixpanel-import": "^3.
|
|
90
|
+
"mixpanel-import": "^3.6.1",
|
|
91
91
|
"p-limit": "^3.1.0",
|
|
92
92
|
"pino": "^9.0.0",
|
|
93
93
|
"pino-pretty": "^11.0.0",
|