@ak--47/dungeon-master 1.4.5 → 1.5.1

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 (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -0,0 +1,468 @@
1
+ ---
2
+ name: write-hooks
3
+ description: Use when an existing dungeon needs engineered story trends or "magic number" patterns — writes the `hook` function using atom helpers and high-level patterns. Adds no new event flags; never mutates the schema.
4
+ argument-hint: [path/to/dungeon.js] [free-text story / trend description]
5
+ model: claude-opus-4-6
6
+ effort: max
7
+ ---
8
+
9
+ # Write Hooks
10
+
11
+ Engineer story trends into the dungeon at `$ARGUMENTS` (first positional arg)
12
+ based on the story description (remaining args).
13
+
14
+ ## Scope
15
+
16
+ This skill writes the `hook` function ONLY. It assumes the dungeon's schema is
17
+ already complete (produced by `create-dungeon`). After writing, hand off to
18
+ `/verify-dungeon` to confirm the engineered patterns actually appear.
19
+
20
+ In scope:
21
+ - The `hook: function(record, type, meta) { ... }` body
22
+ - Documentation comments above the hook explaining each engineered pattern,
23
+ including a reference Mixpanel report block per pattern
24
+
25
+ Out of scope:
26
+ - Schema changes (events, properties, funnels, superProps, userProps).
27
+ Only modify schema if the hook can't possibly work without a new field —
28
+ and even then, prefer changing the value enumeration over adding a new field.
29
+ - New top-level config knobs.
30
+ - Removing the `hook: function...` body to start over with a new schema.
31
+
32
+ ## Reference reading
33
+
34
+ - `lib/hook-helpers/index.js` — atoms (cohort, mutate, timing, inject,
35
+ identity). One file per group; full JSDoc on each atom.
36
+ - `lib/hook-patterns/index.js` — high-level recipes (one per Mixpanel
37
+ analysis type).
38
+ - `lib/verify/emulate-breakdown.js` — what `verify-dungeon` will check.
39
+ - `dungeons/user/my-buddy.js` — reference dungeon using a mix of atoms and
40
+ hand-rolled logic.
41
+ - `dungeons/technical/pattern-*.js` — five minimal pattern fixtures, one per
42
+ recipe.
43
+ - `HOOKS.md` — encyclopedia of hook recipes organized by story pattern. Contains
44
+ 17+ worked examples with code snippets, Mixpanel report instructions, and
45
+ adaptation notes. **Start here** to find the right pattern for your story.
46
+
47
+ ## Hook execution model
48
+
49
+ Hooks fire in this order per user (see `CLAUDE.md` for the canonical reference):
50
+
51
+ 1. `"user"` — profile created. Mutate in place; return ignored.
52
+ 2. `"scd-pre"` — SCD entries created. Mutate in place OR return new array.
53
+ 3. For each funnel: `"funnel-pre"` → `"event"` (per step) → `"funnel-post"`.
54
+
55
+ **`funnel-pre` is now reliable for temporal patterns.** Usage funnels advance a
56
+ cursor after each run, so successive `meta.firstEventTime` values spread across
57
+ the user's active window. Persona and world-event modifiers apply BEFORE the
58
+ hook — the hook has final authority on `conversionRate`, `timeToConvert`, and
59
+ `props`.
60
+ 4. `"event"` — for non-funnel standalone events. Return value REPLACES the event.
61
+ 5. `"everything"` — array of ALL the user's events. Return array to replace.
62
+
63
+ **Most engineered trends belong in `everything`.** It sees the full user stream,
64
+ has access to `meta.profile` / `meta.scd` / `meta.authTime` / `meta.isPreAuth`,
65
+ and you can mutate freely.
66
+
67
+ Storage-only hooks (`ad-spend`, `group`, `mirror`, `lookup`) fire later in the
68
+ pipeline and don't see the same `meta` shape.
69
+
70
+ ## Hook meta — identity context
71
+
72
+ Inside `funnel-pre` and `funnel-post`:
73
+ - `meta.isFirstFunnel: boolean`
74
+ - `meta.isBorn: boolean` (user born inside dataset window)
75
+ - `meta.attemptsConfig: { min, max, conversionRate? } | null`
76
+ - `meta.attemptNumber, meta.totalAttempts, meta.isFinalAttempt`
77
+
78
+ Inside `everything`:
79
+ - `meta.authTime: number | null` — unix-ms of the stitch event, null if never authed
80
+ - `meta.isPreAuth(event): boolean` — convenience predicate
81
+
82
+ Pattern: gate trend logic on `meta.isFinalAttempt` so failed prior attempts
83
+ don't get the same treatment as the converted attempt.
84
+
85
+ Inside `funnel-pre` and `funnel-post` (when experiment is active):
86
+ - `meta.experiment.name: string` — experiment name
87
+ - `meta.experiment.variantName: string` — assigned variant
88
+ - `meta.experiment.variantIndex: number` — 0-based index
89
+ - `meta.experiment.conversionMultiplier: number`
90
+ - `meta.experiment.ttcMultiplier: number`
91
+ - `meta.experiment` is `null` when no experiment or funnel run is before start date
92
+
93
+ Pattern: use `funnel-post` + `meta.experiment` to inject variant-specific
94
+ downstream effects:
95
+
96
+ ```js
97
+ if (type === 'funnel-post' && meta.experiment) {
98
+ if (meta.experiment.variantName === 'Variant B') {
99
+ // Winner variant: inject downstream engagement event
100
+ const last = record[record.length - 1];
101
+ record.push(cloneEvent(last, {
102
+ event: 'Agenda Generated',
103
+ time: dayjs(last.time).add(5, 'minutes').toISOString(),
104
+ }));
105
+ }
106
+ }
107
+ ```
108
+
109
+ ## Atom + pattern catalog
110
+
111
+ ### Atoms (`@ak--47/dungeon-master/hook-helpers`)
112
+
113
+ | File | Atom | Purpose |
114
+ |------|------|---------|
115
+ | cohort | `binUsersByEventCount(events, eventName, bins)` | Classify by per-user event count (Insights total events) |
116
+ | cohort | `binUsersByEventInRange(events, eventName, start, end, bins)` | Same, time-windowed |
117
+ | cohort | `countEventsBetween(events, eventA, eventB)` | Count between first A and first B |
118
+ | cohort | `userInProfileSegment(profile, key, values)` | Profile-property cohort check |
119
+ | mutate | `cloneEvent(template, overrides)` | Spread+override a template event |
120
+ | mutate | `dropEventsWhere(events, predicate)` | In-place filter with count |
121
+ | mutate | `scaleEventCount(events, eventName, factor)` | >1 clones, <1 drops. **Targets Insights Total reports.** For frequency-distribution movement use `injectOnNewDays` |
122
+ | mutate | `scalePropertyValue(events, predicate, prop, factor)` | Multiply numeric prop |
123
+ | mutate | `shiftEventTime(event, deltaMs)` | Shift one event's time |
124
+ | timing | `scaleTimingBetween(events, A, B, factor)` | Scale gap between first A and next B |
125
+ | timing | `scaleFunnelTTC(funnelEvents, factor)` | Scale all step offsets from anchor |
126
+ | timing | `findFirstSequence(events, [names], maxGapMin)` | Detect ordered run within window |
127
+ | inject | `injectAfterEvent(events, source, template, gapMs, overrides)` | Splice clone after source |
128
+ | inject | `injectBetween(events, A, B, template, overrides)` | Splice at midpoint |
129
+ | inject | `injectBurst(events, template, count, anchorTime, spreadMs, overrides)` | Burst around anchor |
130
+ | inject | `injectOnNewDays(events, eventName, targetDistinctDays)` | **Cohort-only.** Spreads injections across previously-empty days. Use for cohort-conditional active-day boosts; for global active-day shape, use `Dungeon.avgActiveDaysPerUser` config knob. |
131
+ | identity | `isPreAuthEvent(event, authTime)` | Standalone variant of meta.isPreAuth |
132
+ | identity | `splitByAuth(events, authTime)` | { preAuth, postAuth, stitch } partition |
133
+
134
+ ### Hook anti-patterns
135
+
136
+ - **DO NOT engineer global active-day distribution in hooks.** Use the
137
+ `Dungeon.avgActiveDaysPerUser` config knob — it's a concentrator that
138
+ preserves total event count while clustering events onto fewer days.
139
+ Hooks own cohort-conditional patterns ("premium users get 7+ days") only.
140
+
141
+ ### Intentional strict-bar deviation is OK
142
+
143
+ The engine guarantees the no-hook baseline (`dungeons/technical/simplest.js`)
144
+ satisfies the per-macro strict bar across the 194-combo sweep matrix
145
+ (see [CLAUDE.md "Engine guarantees"](../../../CLAUDE.md#engine-guarantees)).
146
+ **Hooks can intentionally violate the strict bar** for legitimate stories:
147
+
148
+ - **Decline + churn cohort** (engagementDecay or `everything`-hook event-drop)
149
+ produces tail_ratio < 0.4 — well below the decline bar's 0.4 floor. This is
150
+ the design intent of a sunset story.
151
+ - **Viral hook + persona-driven late-cohort lift** can push the spike above
152
+ the viral preset's 7.0 cap. Hockey-stick stories are louder than the engine
153
+ baseline.
154
+ - **World-event spike** (e.g., a launch-day burst of 5x normal volume) creates
155
+ a single-day right-edge spike above the spike cap.
156
+
157
+ When you write a hook that intentionally violates the strict bar, document the
158
+ deviation in the dungeon's overview JSDoc + the hook's pattern documentation
159
+ block. Engine-validation guarantees apply to **no-hook configs only**; hooks
160
+ own their shape.
161
+ - **DO NOT hand-sort `everything` hook output.** The engine auto-sorts events
162
+ ascending by time after `everything` returns (default ON; opt out via
163
+ `autoSortAfterEverything: false`). Cloned events with arbitrary timestamps
164
+ no longer need explicit sort calls.
165
+ - **DO NOT stamp UTM properties from scratch in attribution hooks.** The
166
+ engine caps UTM stamping at `maxTouchpointsPerUser` (default 10) per user,
167
+ sampled across lifetime. Stamping fresh would push users past the cap; your
168
+ stamps would land outside Mixpanel's last-10 lookback window. OVERWRITE
169
+ engine-stamped values instead (e.g., `event.utm_source = "google"` on
170
+ already-stamped touches).
171
+
172
+ ### When to use the verifier primitives
173
+
174
+ When designing a story, check if any of the new primitives match before
175
+ writing a custom hook:
176
+
177
+ - **Retention curves** ("70% retain at day 1, 30% at day 7") — verify with
178
+ `emulateBreakdown({ type: 'retention', cohortEvent, returnEvent, dayBuckets })`.
179
+ No special hook needed; engineer cohort behavior via `engagementDecay`,
180
+ `dropEventsWhere`, or per-user filtering in `everything`.
181
+ - **Session metrics** ("avg session has 6 events, lasts 4 minutes") — verify
182
+ with `emulateBreakdown({ type: 'sessionMetrics' })`. Trust pre-stamped
183
+ `session_id`. Engineer via `avgEventsPerUserPerDay` + `engagementDecay`.
184
+ - **Reentry funnels** ("power users complete the funnel 3+ times") — set
185
+ `Funnel.reentry: true` (verifier hint). Engineer multiple completions via
186
+ `funnel-post` injecting cloned funnel sequences for that cohort.
187
+ - **Exclusion patterns** ("rage-clickers never convert") — declare an event
188
+ in `events[]` (e.g., `rage_click`), set `Funnel.exclusionEvents: ['rage_click']`.
189
+ The generator stamps it on non-converters; the verifier terminates the
190
+ attempt when it sees one.
191
+ - **HPC / per-cart funnels** ("checkout completion per item type") — use
192
+ `evaluateFunnelHPC(events, steps, holdProperty)` directly (not auto-routed
193
+ through `funnelFrequency`).
194
+ - **Step filters** ("only iOS users complete step 2") — set
195
+ `Funnel.stepFilters: { 1: { prop: 'platform', op: 'eq', value: 'iOS' }}`.
196
+ - **Time-series trends** ("conversion rises week over week") — wrap any
197
+ breakdown with `timeBucket: 'week'`. Engineer via temporal-windowed hooks
198
+ using `DATASET_START.add(N, 'days')`.
199
+ - **Identity-model dungeons** — when `avgDevicePerUser > 0` or
200
+ `hasAnonIds: true`, ALWAYS pass `profiles` to verification. Auto-builds
201
+ identity map merging pre-auth `device_id` events with post-auth `user_id`.
202
+
203
+ **Schema-first reminder:** exclusion events must be declared in `events[]`
204
+ before referencing them as `Funnel.exclusionEvents` — the validator throws
205
+ on undeclared entries.
206
+
207
+ ### Patterns (`@ak--47/dungeon-master/hook-patterns`)
208
+
209
+ Higher-level recipes. Each maps to ONE Mixpanel analysis the verify-dungeon
210
+ emulator can re-derive.
211
+
212
+ | Pattern | Mixpanel analysis | Hook type |
213
+ |---------|-------------------|-----------|
214
+ | `applyFrequencyByFrequency` | Insights — count(A) by per-user count(B) | everything |
215
+ | `applyFunnelFrequencyBreakdown` | Funnels — completion by per-user count(X) | funnel-post |
216
+ | `applyAggregateByBin` | Insights — avg(prop X) by per-user count(B) | everything |
217
+ | `applyTTCBySegment` | Funnel TTC — broken down by user-property segment | funnel-post |
218
+ | `applyAttributedBySource` | Conversions by Source (first/last touch) | everything |
219
+
220
+ Use a pattern when the trend matches its analysis 1:1. Drop down to atoms when
221
+ the trend is bespoke or composite.
222
+
223
+ ## Anti-flag-stamping rule (HARD WALL)
224
+
225
+ Hooks MUST NOT add new properties to records. The schema (config) defines what
226
+ properties exist; hooks modify VALUES of existing properties or inject events
227
+ cloned from existing ones.
228
+
229
+ DO NOT WRITE:
230
+ ```js
231
+ record.is_whale = true; // ❌ flag-stamping
232
+ record.cohort = "engaged"; // ❌ flag-stamping
233
+ record.was_dropped = false; // ❌ flag-stamping
234
+ event.engineered_pattern_id = 5; // ❌ flag-stamping
235
+ ```
236
+
237
+ DO WRITE:
238
+ ```js
239
+ record.amount *= 3; // ✅ scale existing numeric prop
240
+ record.payday = true; // ✅ ONLY if `payday: [false]` exists in event config
241
+ const clone = cloneEvent(template, {time, user_id}); // ✅ clone existing event
242
+ events.push(clone); // ✅ inject from template
243
+ return events.filter(e => !shouldDrop(e)); // ✅ filter inside `everything`
244
+ ```
245
+
246
+ If a trend genuinely needs a new property and the schema doesn't have it, add
247
+ the property to the EVENT CONFIG with a default value (typically `[null]` or
248
+ `[false]`), not via the hook.
249
+
250
+ ## Identity-aware hook patterns
251
+
252
+ When a dungeon uses `isAuthEvent`, hooks can branch on auth state:
253
+
254
+ ```js
255
+ hook: function(record, type, meta) {
256
+ if (type !== 'everything' || !Array.isArray(record)) return record;
257
+ // Drop pre-auth funnel attempts that fired errors — analytics cleanup
258
+ return record.filter(e => !(e.event === 'API Error' && meta.isPreAuth(e)));
259
+ }
260
+ ```
261
+
262
+ When a dungeon uses funnel `attempts`, hooks can reach into individual attempts
263
+ via funnel-post meta:
264
+
265
+ ```js
266
+ if (type === 'funnel-post' && meta.isFirstFunnel && !meta.isFinalAttempt) {
267
+ // Failed prior attempt — reduce its event count to model "abandoned quickly"
268
+ scaleEventCount(record, record[0].event, 0.5);
269
+ }
270
+ ```
271
+
272
+ ## Pattern documentation block
273
+
274
+ Above the `hook` function (or in the dataset overview comment), document each
275
+ engineered pattern with a Mixpanel report block. This is what verify-dungeon
276
+ checks against and what consumers read to understand the dataset.
277
+
278
+ ```
279
+ * ─────────────────────────────────────────────────────────────────────────
280
+ * 1. POWER USERS BUY 3X MORE (everything, applyFrequencyByFrequency)
281
+ * ─────────────────────────────────────────────────────────────────────────
282
+ *
283
+ * PATTERN: Users with 15+ Browse events buy 3× as often as users with <5 Browse.
284
+ *
285
+ * MIXPANEL REPORT:
286
+ * Type: Insights
287
+ * Event: "Purchase"
288
+ * Measure: Frequency Distribution
289
+ * Breakdown: per-user count of "Browse"
290
+ * Expected ratio: bin>=15 / bin<5 ≈ 3x (within ±15%)
291
+ ```
292
+
293
+ ## Hook Ordering Within `everything`
294
+
295
+ The order of operations inside the everything hook matters when hooks interact:
296
+
297
+ 1. **SuperProp stamping** — stamp profile values onto events (always first)
298
+ 2. **Temporal value mutations that DON'T need cloned events** — e.g., version stamping
299
+ 3. **Behavioral detection + event cloning** — agentic detection, KYC clones, pro clones, magic number clones
300
+ 4. **Event filtering** — churn, retention, rate-limit drops
301
+ 5. **Temporal value mutations that NEED cloned events** — e.g., spring price boost, gas spike, outage errors (always LAST before sort)
302
+ 6. **Sort** — `userEvents.sort((a, b) => new Date(a.time) - new Date(b.time))`
303
+
304
+ **Why:** If a temporal mutation runs before cloning, cloned events that land in
305
+ the temporal window miss the mutation. Moving temporal value mutations to the
306
+ end ensures ALL events in the window — original and cloned — receive the effect.
307
+
308
+ ## Deprecated Feature Replacement
309
+
310
+ When a dungeon relied on deprecated config blocks (`subscription`, `attribution`,
311
+ `features`, `geo`, `anomalies`) for properties that hooks depend on, those
312
+ properties no longer appear in the data. Replace them:
313
+
314
+ 1. Add the property to `superProps` and `userProps` with default values
315
+ 2. Assign meaningful values in the `user` hook (based on hash, persona, or profile)
316
+ 3. Use the assigned values in `everything` to drive downstream effects
317
+
318
+ Example: deprecated `subscription` → add `subscription_tier` to superProps/userProps,
319
+ assign tiers by hash in user hook, gate conversion/feature effects on tier in everything.
320
+
321
+ ## Cohort Sizing Guidelines
322
+
323
+ Cohort detection conditions must be selective enough to create a meaningful
324
+ control group, but not so broad they catch everyone:
325
+
326
+ | Detection | Problem | Fix |
327
+ |-----------|---------|-----|
328
+ | `events.some(e => e.event === X)` with common X | 90%+ of users qualify | Require 3+ events: `events.filter(...).length >= 3` |
329
+ | `charCodeAt(0) % 50 === 0` | Only 2% of users | Increase modulus denominator or use `% 10` for 10% |
330
+ | `profile.tier === "premium"` | Fixed by config distribution | Adjust userProps distribution if cohort too small |
331
+ | `earlyEvents.length >= 5` for a low-weight event | 0% qualify (impossible threshold) | Check actual distribution first, set at ~80th percentile |
332
+
333
+ Target: 10-30% of users in the affected cohort for clean signal at 10K users.
334
+
335
+ ### Threshold Calibration
336
+
337
+ When a hook gates on "N+ events of type X in first Y days," check the actual
338
+ distribution BEFORE choosing the threshold. With 200 event types and 2.5
339
+ events/user/day, a weight-7 event might produce only ~0.2 per user per day.
340
+ Setting threshold=5 for 7 days means ~0% of users qualify. Run this check
341
+ in your smoke test:
342
+
343
+ ```sql
344
+ SELECT n, COUNT(*) FROM (
345
+ SELECT user_id, COUNT(*) as n FROM events WHERE event = 'X' GROUP BY user_id
346
+ ) GROUP BY n ORDER BY n LIMIT 15;
347
+ ```
348
+
349
+ Set the threshold at approximately the 80th percentile of the distribution.
350
+
351
+ ### Compounding Drop Hooks
352
+
353
+ Use at most ONE drop-based retention hook per dungeon. Multiple hooks that
354
+ each drop events after the same day threshold compound destructively:
355
+
356
+ - Hook A: drop 40% after day 21 for non-loyal users
357
+ - Hook B: drop 60% after day 21 for non-streak users
358
+ - Combined: 76% drop for users in both groups (which is 95% of users)
359
+
360
+ The control group barely exists. Fix: use boost-based patterns
361
+ (`scaleEventCount(events, "X", 1.8)`) for positive cohorts instead of drops
362
+ for negative cohorts. Boosts are additive and don't interact destructively.
363
+ Reserve drops for a single churn/retention effect per dungeon.
364
+
365
+ ## Common Hook Pitfalls
366
+
367
+ Apply these BEFORE handing off to `/verify-dungeon`. See HOOKS.md §9 for full recipes.
368
+
369
+ ### isStrictEvent: false is NOT optional for hook-read events
370
+
371
+ If your hook reads `event === 'X'` and `X` is also a funnel-step event, the
372
+ validator auto-promotes it to `isStrictEvent: true` and the engine
373
+ won't emit standalone occurrences. Your cohort goes empty.
374
+
375
+ ```js
376
+ // BAD — login is a funnel step + read by hook
377
+ events: [{ event: 'login', weight: 4, properties: {...} }]
378
+ // GOOD — explicit opt-out preserves standalone occurrences
379
+ events: [{ event: 'login', weight: 4, isStrictEvent: false, properties: {...} }]
380
+ ```
381
+
382
+ Audit: any event referenced in the `everything` hook by name AND appearing
383
+ as a funnel step needs `isStrictEvent: false`.
384
+
385
+ ### Reentry on per-instance loops
386
+
387
+ Funnels named "X loop" / "X cycle" / "session" / repeated user behaviors
388
+ need `Funnel.reentry: true`. Without it, the engine produces ONE funnel
389
+ sequence per user — no recurring loops. Examples that need it: workout
390
+ loop, match flow, search-to-book, order fulfillment, engagement loop, tour
391
+ funnel.
392
+
393
+ ### Hash-based cohorts produce textbook signals
394
+
395
+ Cleanest hidden-cohort pattern. No flag, no schema mutation, easy to verify
396
+ deterministically:
397
+
398
+ ```js
399
+ // 2% whales with 50x trade amount → long-tail Insights distribution
400
+ const isWhale = uid.charCodeAt(0) % 50 === 0;
401
+ if (isWhale && e.event === 'swap') e.trade_amount_usd *= 50;
402
+ ```
403
+
404
+ Use a large multiplier (≥10x, ideally 50x) so the signal beats soup noise.
405
+ Use `% 50` for ~2% whales, `% 25` for ~4% bots, `% 10` for ~10% cohorts.
406
+
407
+ ### Hook ordering inside `everything`
408
+
409
+ If hook A injects events that hook B mutates, B must run AFTER A in the
410
+ same `everything` block — otherwise the injected events miss B's mutation.
411
+ The existing "Hook Ordering Within `everything`" section above codifies
412
+ this; the eval revealed it as the single most common subtle bug.
413
+
414
+ ### Avoid behavioral cohorts where the gating event IS the signal
415
+
416
+ If hook says "users who did X often → reduce X count", the verifier sees
417
+ inverted signal because users with high X naturally have higher absolute
418
+ counts even after reduction. Either:
419
+ - Use hash cohort for the same effect, OR
420
+ - Verify by per-user post/pre ratio instead of raw counts
421
+
422
+ ### Don't reference profile.X unless X is a defined userProp
423
+
424
+ ```js
425
+ // BAD — profile.level isn't in userProps; resolves to undefined
426
+ if (meta.profile.level >= 50) e.gold_earned *= 3;
427
+ // GOOD — verify by SPREAD instead, OR add level to userProps with weighted distribution
428
+ ```
429
+
430
+ When the hook references a missing profile field, you can still get the
431
+ data spread you want (gold range), but the cohort can't be analytically
432
+ recovered. Either add the userProp or rewrite the hook to use a hash
433
+ cohort.
434
+
435
+ ## Workflow
436
+
437
+ 1. Read the dungeon at `$ARGUMENTS[0]` and understand the existing schema.
438
+ 2. Translate the user's story description into 3–5 engineered patterns.
439
+ Consult `HOOKS.md` for recipe ideas that match the user's story. Each recipe
440
+ includes the hook type, code snippet, and Mixpanel report format.
441
+ 3. For each pattern:
442
+ - Pick a pattern from `lib/hook-patterns/` if it fits the analysis 1:1.
443
+ - Otherwise compose atoms from `lib/hook-helpers/`.
444
+ - Document the pattern in a comment block (Mixpanel report instructions).
445
+ 4. Write the `hook` function, importing atoms/patterns at the top of the file.
446
+ 5. Smoke-test:
447
+ ```bash
448
+ node scripts/verify-runner.mjs <dungeon> verify-dungeon --small
449
+ ```
450
+ Confirm the run completes without errors.
451
+ 6. Hand off:
452
+ ```
453
+ /verify-dungeon <dungeon>
454
+ ```
455
+ If verify-dungeon returns WEAK, NONE, or INVERSE on any pattern, return to
456
+ step 4 and refine. Iterate until all patterns score STRONG or NAILED.
457
+
458
+ ## Stopping condition
459
+
460
+ Stop after `/verify-dungeon` reports all engineered patterns as STRONG or NAILED,
461
+ OR after three iterations without convergence — at that point, document what's
462
+ still off in the dungeon's overview comment and report the gap to the user.
463
+
464
+ ## Output
465
+
466
+ Modify the dungeon file in place. Add the `hook` function. Add the imports.
467
+ Add the documentation block above the config. Do NOT modify any other file.
468
+ Tell the user to run `/verify-dungeon <dungeon>` next.