@ak--47/dungeon-master 1.5.4 → 1.6.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 (149) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: analyze-soup
3
3
  description: Use when investigating TimeSoup parameters, diagnosing event-distribution shape, or comparing soup configs — runs a dungeon locally and analyzes time distribution at week/day/hour/minute granularities, producing a soup-analysis.md diagnostic report.
4
- argument-hint: [dungeon path, e.g. dungeons/soup-test.js]
4
+ argument-hint: [dungeon path, e.g. dungeons/technical/simplest.js]
5
5
  model: claude-opus-4-6
6
6
  effort: max
7
7
  ---
@@ -10,7 +10,7 @@ effort: max
10
10
 
11
11
  Run a dungeon and analyze the time distribution of generated events to evaluate TimeSoup parameters.
12
12
 
13
- **Dungeon file:** `$ARGUMENTS` (default: `dungeons/soup-test.js`)
13
+ **Dungeon file:** `$ARGUMENTS` (default: `dungeons/technical/simplest.js`)
14
14
 
15
15
  ## Step 1: Run the Dungeon
16
16
 
@@ -32,10 +32,19 @@ Wait for generation to complete. Note the event count and EPS.
32
32
 
33
33
  Run these DuckDB queries against the generated JSONL file. Use `duckdb` CLI.
34
34
 
35
+ **All bucketing is UTC — do not convert timezones.** TimeSoup applies its
36
+ day-of-week / hour-of-day weights in UTC, the engine's calendar-day logic
37
+ (`avgActiveDaysPerUser`, lifecycle periods, session midnight splits) is UTC,
38
+ and `/create-project` pins Mixpanel projects to UTC. Casting `time::timestamp`
39
+ on the ISO strings keeps the UTC wall time. Converting to a local zone (e.g.
40
+ `America/Los_Angeles`, −8h) shifts late-evening UTC events onto the previous
41
+ calendar day — the DOW histogram you'd analyze would not be the one the soup
42
+ generated or the one Mixpanel reports.
43
+
35
44
  ### 2a. Week over Week
36
45
  ```bash
37
46
  duckdb -c "
38
- SELECT date_trunc('week', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as week,
47
+ SELECT date_trunc('week', time::timestamp) as week,
39
48
  count(*) as events
40
49
  FROM read_json_auto('./data/soup-analysis-EVENTS.json')
41
50
  GROUP BY 1 ORDER BY 1;
@@ -45,7 +54,7 @@ GROUP BY 1 ORDER BY 1;
45
54
  ### 2b. Day over Day
46
55
  ```bash
47
56
  duckdb -c "
48
- SELECT date_trunc('day', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as day,
57
+ SELECT date_trunc('day', time::timestamp) as day,
49
58
  count(*) as events
50
59
  FROM read_json_auto('./data/soup-analysis-EVENTS.json')
51
60
  GROUP BY 1 ORDER BY 1;
@@ -55,10 +64,10 @@ GROUP BY 1 ORDER BY 1;
55
64
  ### 2c. Hour over Hour (last 7 days only)
56
65
  ```bash
57
66
  duckdb -c "
58
- SELECT date_trunc('hour', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as hour,
67
+ SELECT date_trunc('hour', time::timestamp) as hour,
59
68
  count(*) as events
60
69
  FROM read_json_auto('./data/soup-analysis-EVENTS.json')
61
- WHERE (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles') > (SELECT max((time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
70
+ WHERE time::timestamp > (SELECT max(time::timestamp) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
62
71
  GROUP BY 1 ORDER BY 1;
63
72
  "
64
73
  ```
@@ -66,10 +75,10 @@ GROUP BY 1 ORDER BY 1;
66
75
  ### 2d. Minute over Minute (last 24 hours only)
67
76
  ```bash
68
77
  duckdb -c "
69
- SELECT date_trunc('minute', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as minute,
78
+ SELECT date_trunc('minute', time::timestamp) as minute,
70
79
  count(*) as events
71
80
  FROM read_json_auto('./data/soup-analysis-EVENTS.json')
72
- WHERE (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles') > (SELECT max((time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) - interval '1 day' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
81
+ WHERE time::timestamp > (SELECT max(time::timestamp) - interval '1 day' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
73
82
  GROUP BY 1 ORDER BY 1;
74
83
  "
75
84
  ```
@@ -78,14 +87,14 @@ GROUP BY 1 ORDER BY 1;
78
87
  ```bash
79
88
  duckdb -c "
80
89
  WITH daily AS (
81
- SELECT date_trunc('day', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as day, count(*) as events
90
+ SELECT date_trunc('day', time::timestamp) as day, count(*) as events
82
91
  FROM read_json_auto('./data/soup-analysis-EVENTS.json')
83
92
  GROUP BY 1
84
93
  ),
85
94
  hourly AS (
86
- SELECT date_trunc('hour', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as hour, count(*) as events
95
+ SELECT date_trunc('hour', time::timestamp) as hour, count(*) as events
87
96
  FROM read_json_auto('./data/soup-analysis-EVENTS.json')
88
- WHERE (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles') > (SELECT max((time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
97
+ WHERE time::timestamp > (SELECT max(time::timestamp) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
89
98
  GROUP BY 1
90
99
  )
91
100
  SELECT 'daily' as granularity,
@@ -112,7 +121,7 @@ FROM hourly;
112
121
  ```bash
113
122
  duckdb -c "
114
123
  WITH daily AS (
115
- SELECT date_trunc('day', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as day, count(*) as events
124
+ SELECT date_trunc('day', time::timestamp) as day, count(*) as events
116
125
  FROM read_json_auto('./data/soup-analysis-EVENTS.json')
117
126
  GROUP BY 1
118
127
  )
@@ -51,7 +51,14 @@ Out of scope (hand off to `write-hooks`):
51
51
  For an encyclopedia of hook patterns, recipes, and real-world examples:
52
52
  see `HOOKS.md` at the project root.
53
53
 
54
- These config keys are silently ignored DO NOT use them: `subscription`, `attribution`, `geo`, `features`, `anomalies`. Recreate with hooks via `write-hooks`.
54
+ > ⚠️ **Silently-ignored config keys — do not use.** The validator STRIPS these
55
+ > five keys before generation: `subscription`, `attribution`, `geo`,
56
+ > `features`, `anomalies`. They produce **zero data** — only a verbose-mode
57
+ > warning. A dungeon that "configures" churn via `subscription:` or campaign
58
+ > lift via `attribution:` is configuring nothing, and the omission is
59
+ > invisible at smoke-test scale. Recreate those behaviors with hooks via
60
+ > `/write-hooks` (churn cohorts, UTM biasing, geo weighting, feature-flag
61
+ > cohorts, anomaly windows all have recipes in `HOOKS.md`).
55
62
 
56
63
  ## Reference reading
57
64
 
@@ -62,8 +69,10 @@ Before writing any code, scan:
62
69
  with full JSDoc. **Treat this as the source of truth.**
63
70
  - `lib/utils/utils.js` — `pickAWinner`, `weighNumRange`, `initChance`, `exhaust`,
64
71
  `takeSome` for property value distributions
65
- - `dungeons/vertical/sass.js` — B2B reference dungeon with full identity model
66
- - `dungeons/user/my-buddy/my-buddy.js` — consumer-app reference (gitignored)
72
+ - `dungeons/vertical/sass/sass.js` — B2B reference dungeon with full identity model
73
+ - `dungeons/vertical/ecommerce/ecommerce.js` — canonical consumer exemplar:
74
+ schema, funnels, stories export, and full hook layout (what your schema
75
+ becomes after `/write-hooks`)
67
76
  - `dungeons/technical/identity-model-verify.js` — minimal identity-model fixture
68
77
 
69
78
  ## File structure
@@ -88,6 +97,7 @@ import * as v from "ak-tools";
88
97
  * APP: <2-4 line description: what users do, core flow, monetization>
89
98
  * SCALE: <numUsers> users, ~<numEvents> events, <numDays> days (<start> → <end>)
90
99
  * CORE LOOP: <event1> → <event2> → <event3> → ...
100
+ * VALUE MOMENT: <the one event representing realized value — lifecycle hooks anchor here>
91
101
  *
92
102
  * EVENTS (N):
93
103
  * <event name (weight)> > ... (sorted by weight desc)
@@ -160,7 +170,7 @@ KNOBS (extracted tunable constants — timing, thresholds, multipliers),
160
170
  HOOK STATE (module-level Maps/Sets used across users), and HELPER FUNCTIONS
161
171
  (per-type handlers like `handleEventHooks`, `handleEverythingHooks`).
162
172
  `config.hook` becomes a thin dispatcher delegating to the helpers. See
163
- `dungeons/vertical/ecommerce.js` as the canonical exemplar.
173
+ `dungeons/vertical/ecommerce/ecommerce.js` as the canonical exemplar.
164
174
 
165
175
  ## Required components
166
176
 
@@ -196,6 +206,30 @@ When using experiments, include `$experiment_started` in the events array with
196
206
  }}
197
207
  ```
198
208
 
209
+ **Design the vocabulary for the analyses hooks will target (v1.6):**
210
+
211
+ - **Session-friendly next-events.** Flows (Sankey) reads collapse when an
212
+ anchor event can plausibly be followed by 15 different event types — every
213
+ branch gets a sliver and no engineered path clears the top level. For each
214
+ high-traffic anchor (post-login, post-search, post-add-to-cart), keep the
215
+ realistic next-event vocabulary to **≤5–6 event types**; if the app
216
+ genuinely has more surfaces, give the rare ones weight 1 so the top paths
217
+ stay legible. A path-share hook needs ~20–25% share to survive a Sankey's
218
+ top-3 — impossible against a 15-way fan-out.
219
+ - **Designate a value moment.** Pick ONE event that represents realized value
220
+ (first workout logged, order delivered, report generated) and record it in
221
+ the OVERVIEW block as `VALUE MOMENT: <event>`. Lifecycle stories
222
+ (dormancy → resurrection waves) anchor on the value moment — hooks gate or
223
+ inject THAT event, not generic page views. A schema with no obvious value
224
+ moment forces `write-hooks` to invent one badly.
225
+ - **Hidden-event hygiene.** Heartbeat/telemetry events (`ping`,
226
+ `app foregrounded`, `position updated`) at weight ≥5 drown every Sankey
227
+ level — every top path becomes anchor → heartbeat → heartbeat. Mixpanel
228
+ Flows (and the emulator's `topPaths` via `hiddenEvents`) can hide them at
229
+ query time, but the default view is what demos show. If realism demands
230
+ heartbeats, keep them at weight 1–2 and list them in the OVERVIEW so
231
+ `write-hooks` knows to pass `hiddenEvents` in flows stories.
232
+
199
233
  ### 2. Funnels (3–6)
200
234
 
201
235
  - First funnel: includes `isFirstEvent` AND has `isAuthEvent: true` on the
@@ -237,6 +271,32 @@ event.
237
271
 
238
272
  **Funnels representing loops need `reentry: true`.** Any funnel named "X loop" / "X cycle" / "session" / per-instance recurring behavior must declare `reentry: true`. Without it, the engine emits one sequence per user and downstream "power user" / "daily active" cohorts have no behavioral signal to bin on.
239
273
 
274
+ **Structural trend engineering — duplicate funnels instead of reaching for
275
+ hooks.** Not every trend needs a hook: initial conditions can raise or lower
276
+ a metric by themselves. The funnel array is a set of knobs — duplicate a
277
+ funnel and swap steps, props, `conversionRate`, `timeToConvert`, or `weight`
278
+ to architect a comparison directly into the schema:
279
+
280
+ - **Exclusion-step story**: Mixpanel funnels can exclude users who did
281
+ `foo` between A and B. There's no `excludeSteps` config — model it with
282
+ two funnels, `[A, B, C]` and `[A, B, foo, C]`, giving the second a lower
283
+ `conversionRate`. The report shows the conversion dip for the
284
+ detour-takers with zero hook code.
285
+ - **Segment-split story**: two copies of the same sequence with different
286
+ `props` (`{ checkout_version: "v1" }` vs `"v2"`) and different
287
+ `conversionRate` — an A/B story without the experiment machinery (use
288
+ `experiment` when you want `$experiment_started` + variant reports).
289
+ - **Speed story**: same sequence, different `timeToConvert`, split by a
290
+ funnel-level prop — time-to-convert deltas appear structurally.
291
+ - **Mix-shift story**: `weight` controls how often each variant funnel is
292
+ chosen; a heavy-weighted low-converting path drags the blended rate.
293
+
294
+ Prefer structure when the story is a *between-path comparison* (this path
295
+ converts worse / slower than that one). Reach for hooks when the story is a
296
+ *within-cohort behavior* (these users do more of X over time, this segment's
297
+ values differ). Structural trends are cheaper to verify — the knob IS the
298
+ expected value.
299
+
240
300
  ### 3. SuperProps (2–3)
241
301
 
242
302
  Properties present on EVERY event. Common picks: `Plan`, `Region`, `Platform`,
@@ -554,7 +614,7 @@ When designing event properties, always consider which Mixpanel type best repres
554
614
  app/customer, then **create `dungeons/user/<name>/` if it doesn't already exist**
555
615
  (`mkdir -p dungeons/user/<name>`) and write the dungeon to
556
616
  `dungeons/user/<name>/<name>.js` (e.g. `dungeons/user/acme/acme.js`). Folder and
557
- file share the name, matching `kodiak/kodiak.js`, `my-buddy/my-buddy.js`.
617
+ file share the name, matching the vertical layout (`ecommerce/ecommerce.js`).
558
618
 
559
619
  This keeps `dungeons/user/` organized — EVERYTHING about this dungeon lives in
560
620
  the same folder: `hook-results.md` + `hook-query-log.txt` +
@@ -25,7 +25,7 @@ in order:
25
25
  1. **createProject** — name derived from the dungeon's `OVERVIEW` (`NAME:` line), region `US`, timezone `UTC` (set as a follow-up by the endpoint).
26
26
  2. **mintServiceAccount** — `admin`, expires `+30 days`, scoped to the new project. This is what the dungeon uses to **send** data.
27
27
  3. **addGroupKey** — one per `groupKeys` entry in the dungeon (`property_name` + a titleized `display_name`). Skipped if the dungeon has no group keys.
28
- 4. **setBusinessContext** — markdown built from the dungeon's `OVERVIEW` + `HOOK STORIES` comment blocks (via the package's `extractComments`) plus an events/funnels/props/group-keys summary, capped at 50k chars.
28
+ 4. **setBusinessContext** — markdown built from the dungeon's `OVERVIEW` comment block plus the `stories` named export (per story: `narrative` + `mixpanelReport` + intentional deviations); dungeons without stories fall back to the `HOOK STORIES` comment scrape (via the package's `extractComments`). Plus an events/funnels/props/group-keys summary, capped at 50k chars. The dry-run plan prints which source was used.
29
29
  5. **write-back** — replaces the dungeon's `credentials: { … }` block with `{ token, projectId, serviceAccount, serviceSecret, region }`.
30
30
 
31
31
  **Always creates a fresh project.** Re-running mints a new project and overwrites
@@ -10,7 +10,9 @@
10
10
  * 1. createProject (sets timezone UTC as a follow-up)
11
11
  * 2. mintServiceAccount (admin, +30d) — scoped to the new project
12
12
  * 3. addGroupKey (only if the dungeon declares groupKeys)
13
- * 4. setBusinessContext (OVERVIEW + HOOK STORIES + schema summary)
13
+ * 4. setBusinessContext (OVERVIEW + the dungeon's `stories` export
14
+ * (narrative + mixpanelReport per story) — falls back to the HOOK
15
+ * STORIES comment scrape when the dungeon has no stories — + schema summary)
14
16
  * 5. write `credentials: { token, projectId, serviceAccount, serviceSecret, region }`
15
17
  * back into the dungeon (gitignored user dungeons — plaintext is fine)
16
18
  *
@@ -27,7 +29,7 @@
27
29
 
28
30
  import { readFileSync, writeFileSync, existsSync } from 'fs';
29
31
  import path, { dirname, resolve } from 'path';
30
- import { fileURLToPath } from 'url';
32
+ import { fileURLToPath, pathToFileURL } from 'url';
31
33
  import dotenv from 'dotenv';
32
34
  import { loadFromFile, extractComments } from '../../../index.js';
33
35
 
@@ -57,12 +59,26 @@ if (!existsSync(dungeonPath)) fail(`dungeon file not found: ${dungeonPath}`);
57
59
  const config = await loadFromFile(dungeonPath);
58
60
  const comments = extractComments(dungeonPath);
59
61
 
62
+ // stories is a NAMED export of JS dungeons (v1.6 story-spec) — the verified
63
+ // behavior contract. Preferred over the comment scrape for business context.
64
+ let stories = null;
65
+ if (/\.(js|mjs)$/i.test(dungeonPath)) {
66
+ try {
67
+ const mod = await import(pathToFileURL(dungeonPath).href);
68
+ if (Array.isArray(mod.stories) && mod.stories.length) stories = mod.stories;
69
+ } catch {
70
+ // loadFromFile above already imported the module; an error here would
71
+ // have surfaced there. Fall back to the comment scrape regardless.
72
+ }
73
+ }
74
+
60
75
  const name = deriveName(comments, dungeonPath);
61
76
  const groupKeys = Array.isArray(config.groupKeys)
62
77
  ? config.groupKeys.filter(Boolean).map(([prop]) => ({ property_name: prop, display_name: titleize(prop) }))
63
78
  : [];
64
79
  const saName = `${slug(name)}-dungeon-sa`.slice(0, 64);
65
- const content = buildContext(name, config, comments, groupKeys);
80
+ const ctxSource = stories ? `stories export (${stories.length} stories)` : 'comment scrape';
81
+ const content = buildContext(name, config, comments, groupKeys, stories);
66
82
 
67
83
  if (dryRun) {
68
84
  printPlan();
@@ -182,13 +198,41 @@ function slug(s) {
182
198
 
183
199
  function isoInDays(days) {
184
200
  const ms = Date.now() + days * 24 * 60 * 60 * 1000;
185
- return new Date(ms).toISOString();
201
+ // Mixpanel's createServiceAccount requires %Y-%m-%dT%H:%M:%SZ — seconds
202
+ // precision, NO milliseconds. toISOString() emits ".sssZ", which the API
203
+ // rejects ("Expiration date … does not match %Y-%m-%dT%H:%M:%SZ").
204
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
186
205
  }
187
206
 
188
- function buildContext(name, config, comments, groupKeys) {
207
+ function buildContext(name, config, comments, groupKeys, stories) {
189
208
  const parts = [`# ${name}`, ''];
190
209
  if (comments.overview) parts.push(comments.overview, '');
191
- if (comments.hookStories) parts.push('## Engineered Behaviors', '', comments.hookStories, '');
210
+
211
+ // Preferred source: the stories export — each story's narrative is the
212
+ // human-readable behavior and mixpanelReport (free-form object) points at
213
+ // the report where it shows. Comment scrape is the fallback for dungeons
214
+ // without stories (schema-only or pre-1.6).
215
+ if (stories?.length) {
216
+ parts.push('## Engineered Behaviors', '');
217
+ parts.push(`${stories.length} machine-verified story patterns (from the dungeon's \`stories\` export):`, '');
218
+ for (const s of stories) {
219
+ const arch = s.archetype ? ` — ${s.archetype}` : '';
220
+ parts.push(`### ${s.id}${arch}`, '');
221
+ if (s.narrative) parts.push(String(s.narrative).trim(), '');
222
+ if (s.mixpanelReport && typeof s.mixpanelReport === 'object') {
223
+ parts.push('How to see it in Mixpanel:');
224
+ for (const [k, v] of Object.entries(s.mixpanelReport)) {
225
+ parts.push(`- **${k}**: ${typeof v === 'string' ? v : JSON.stringify(v)}`);
226
+ }
227
+ parts.push('');
228
+ }
229
+ if (Array.isArray(s.intentionalDeviations) && s.intentionalDeviations.length) {
230
+ parts.push('Notes:', ...s.intentionalDeviations.map((d) => `- ${d}`), '');
231
+ }
232
+ }
233
+ } else if (comments.hookStories) {
234
+ parts.push('## Engineered Behaviors', '', comments.hookStories, '');
235
+ }
192
236
 
193
237
  parts.push('## Schema', '');
194
238
  const events = config.events || [];
@@ -252,7 +296,7 @@ function printPlan() {
252
296
  console.log(`region: ${REGION} timezone: UTC`);
253
297
  console.log(`service acct: ${saName} (role admin, expires +${SA_TTL_DAYS}d)`);
254
298
  console.log(`group keys: ${groupKeys.length ? groupKeys.map((g) => `${g.property_name} → "${g.display_name}"`).join(', ') : '(none)'}`);
255
- console.log(`business ctx: ${content.length} chars`);
299
+ console.log(`business ctx: ${content.length} chars (source: ${ctxSource})`);
256
300
  console.log('');
257
301
  console.log('would POST: createProject → mintServiceAccount' + (groupKeys.length ? ' → addGroupKey' : '') + ' → setBusinessContext');
258
302
  console.log('then write credentials back into the dungeon.');
@@ -268,11 +312,14 @@ function printSummary() {
268
312
  console.log(`region: ${REGION} timezone: UTC`);
269
313
  console.log(`service acct: ${sa ? `${sa.username} (role ${sa.role}, expires ${sa.expires})` : '(FAILED — see warnings)'}`);
270
314
  if (groupKeys.length) {
271
- const added = groupKeyResult?.added?.map((g) => g.property_name).join(', ') || '(none)';
272
- const skipped = groupKeyResult?.skipped?.join(', ') || '(none)';
315
+ // Defensive: the API's `added`/`skipped` entries may be objects, strings, or
316
+ // null. Never let summary formatting throw — it would swallow real warnings.
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)';
273
320
  console.log(`group keys: added [${added}] skipped [${skipped}]`);
274
321
  }
275
- console.log(`business ctx: ${content.length} chars uploaded`);
322
+ console.log(`business ctx: ${content.length} chars uploaded (source: ${ctxSource})`);
276
323
  console.log(`credentials: ${wroteBack ? 'written back into dungeon ✓' : 'NOT written (see warnings)'}`);
277
324
  if (warnings.length) {
278
325
  console.log('');
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: powertools
3
+ description: Use when any task needs the Mixpanel Power Tools API ("use powertools") — schema export (get-schema), event volumes, project CRUD, query methods, macros, or snapshotting a prod project's schema to copy it into a dungeon. Companion to create-project (which handles provisioning specifically).
4
+ argument-hint: [what to do, e.g. "get schema for project 12345" or "copy project 12345 into a dungeon"]
5
+ ---
6
+
7
+ # Power Tools API
8
+
9
+ Base URL: `https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app`
10
+
11
+ ## Auth model — read this first
12
+
13
+ - **GET any endpoint path = documentation, no auth.** Always `curl -s GET <base><path>` before first use of an unfamiliar endpoint — docs include exact body params and response shapes.
14
+ - **POST = execute.** `Authorization: Bearer <oauth-token>` (employee OAuth from repo `.env` `BEARER_TOKEN`, or a customer's OAuth token) or `Basic base64(service_acct:secret)`.
15
+ - **Customer OAuth tokens are accepted** (verified 2026-07-06): `/auth` and `/macro/get-schema` work with a customer token on projects that token can access. `ai_endpoints_allowed: false` for non-employees — the `ai-*` family stays employee-only.
16
+ - **Every POST body** should include `client_id: "dungeon-master"` and `region` (`US` default).
17
+ - **`/auth` accessibility ≠ data access.** `/auth {project_id}` can report `accessible: true` while `get-schema`/`query/*` on the same project return `HTTP 403: Forbidden` (token lacks data-level access, e.g. an employee token on a customer project). Diagnose with `/auth`, but don't trust it for data endpoints. Prefer the token of an actual project member.
18
+
19
+ ## Tools in this skill
20
+
21
+ ### `pt.mjs` — ad-hoc client
22
+
23
+ ```bash
24
+ node .claude/skills/powertools/pt.mjs <path> ['<json-body>'] [--bearer <token>] [--get] [--region US]
25
+ # examples
26
+ node .claude/skills/powertools/pt.mjs /auth '{}'
27
+ node .claude/skills/powertools/pt.mjs /macro/get-schema '{"project_id":"123","include_metadata":true,"verbose":true}'
28
+ node .claude/skills/powertools/pt.mjs /query/getTopEvents --get # docs, no auth
29
+ ```
30
+
31
+ Bearer defaults to `.env` `BEARER_TOKEN`. Merges `client_id`/`region` into the body. Prints pretty JSON to stdout.
32
+
33
+ ### `snapshot-project.mjs` — schema + relative-volume snapshot
34
+
35
+ ```bash
36
+ node .claude/skills/powertools/snapshot-project.mjs <project_id> --bearer <token> \
37
+ [--region US] [--out snapshot.json]
38
+ ```
39
+
40
+ - Uses `/macro/get-schema` (`include_metadata` + `verbose`) + `/query/getTopEvents`. Token (customer or employee) needs data access to the project.
41
+ - Output shape: `{ projectId, projectName, fetchedAt, totalCount, events: [{ name, count, pct, properties: [{name, type, description}] }], userProps: [{name, type, description}], groups: {} }`, events sorted by count desc.
42
+ - **Never captures property values** — schema + volumes only. Snapshots stay privacy-safe by construction.
43
+
44
+ ## Endpoint catalog (the useful subset)
45
+
46
+ GET the path for full docs. Full list: GET `/` and GET `/macro`.
47
+
48
+ **crud** — `/crud/createProject`, `/crud/deleteProject`, `/crud/getProjects`, `/crud/mintServiceAccount`, `/crud/addGroupKey`, `/crud/setBusinessContext` (all used by the create-project skill's `provision.mjs`).
49
+
50
+ **query** — `/query/getTopEvents` (per-event counts, limit≤100 default), `/query/getEventNames`, `/query/getPropertyValues`, `/query/getTopProperties`, `/query/getSegmentation`, `/query/getFunnel`, `/query/listFunnels`, `/query/listCohorts`, `/query/runJQL`. Rate limits: 5 concurrent / 60 per hour; 1h response cache.
51
+
52
+ **macros** — `/macro/get-schema` (always pass `include_metadata: true, verbose: true`; also `include_density: true` for per-(event,property) coverage % and `include_sdk_defaults: true` to keep `$browser`/`$os`-style SDK props — needed for carbon copies), `/macro/analyze-project` (volumes + cardinality + activity), `/macro/enumerate-project`, `/macro/clone-project`, `/macro/clone-boards`, `/macro/delete-entities`, `/macro/dungeon-master` (runs a dungeon config server-side and ingests), `/macro/ai-e2e-dm4` (demo build from a supplied schema, designed for dungeon-master), plus the `ai-*` family (dashboards, cohorts, metrics, schema naming).
53
+
54
+ ### get-schema field notes (verified on a 2,410-event project, 2026-07-06)
55
+
56
+ - Response nests under `json`: `{ json: { events, properties, users, groups, dependencies }, duration_ms }`. Large projects are slow — 2,410 events took ~13 min; run it in the background.
57
+ - Event counts include custom events (`customEventId > 0`) and merged events. Filter `!merged && !(customEventId > 0)` to match the Lexicon UI event count.
58
+ - `properties` includes `mp_*` internals and `$custom_property:<id>` computed-prop references — exclude both when authoring a dungeon (ingestion stamps `mp_*` itself; computed props can't be tracked).
59
+ - `exampleValue` fields contain REAL customer values — never copy them into a dungeon.
60
+ - **Known issue**: `dependencies` can come back empty (`{events:{},properties:{}}`) on large projects even with `verbose: true` + `include_density: true` — the bulk dependency call silently fails. Retry the call; per-event property mapping is unavailable until it succeeds.
61
+
62
+ ## Recipe: copy a prod project into a dungeon
63
+
64
+ Goal: a purely synthetic dungeon with the same events/props/user-props and matching **relative** event volumes. Safe to share — no customer data.
65
+
66
+ 1. **Snapshot** the source project (`snapshot-project.mjs`, customer token unless the employee token has data access). Sanity-check event count + that counts are non-zero.
67
+ 2. **Author the dungeon** from the snapshot (schema-first, per repo hook rules):
68
+ - Take the top-N events covering ≥95% of total volume (`pct` cumsum); note dropped tail in the OVERVIEW comment.
69
+ - `weight` per event ∝ snapshot `count` (normalize so max ≈ 100, min ≥ 1).
70
+ - Properties per event from snapshot; **invent all values** from name/type/description — never copy real values.
71
+ - `userProps` from snapshot; shared high-frequency props → `superProps`.
72
+ - Funnels: best-effort from event-name semantics (or `/query/listFunnels` + `/query/getFunnel` on the source if accessible).
73
+ 3. **Provision** into our org via the create-project skill: `node .claude/skills/create-project/provision.mjs <dungeon> --dry-run` → confirm → live. Uses `.env` `BEARER_TOKEN` + `ORG_ID`, writes `credentials` back into the dungeon.
74
+ 4. **Run**: `node scripts/run-dungeon.mjs <dungeon>`.
75
+ 5. **Verify** relative volumes: top-10 generated events should rank in the same order as the snapshot's top-10.
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * pt.mjs — thin ad-hoc client for the Mixpanel Power Tools API.
5
+ *
6
+ * Usage:
7
+ * node .claude/skills/powertools/pt.mjs <path> ['<json-body>'] [--bearer <token>] [--get] [--region US]
8
+ *
9
+ * Examples:
10
+ * node .claude/skills/powertools/pt.mjs /auth '{}'
11
+ * node .claude/skills/powertools/pt.mjs /macro/get-schema '{"project_id":"123","include_metadata":true,"verbose":true}'
12
+ * node .claude/skills/powertools/pt.mjs /query/getTopEvents --get # endpoint docs, no auth
13
+ *
14
+ * POST bodies are merged with { client_id: "dungeon-master", region }.
15
+ * Bearer defaults to BEARER_TOKEN in the repo .env (employee OAuth). Customer
16
+ * OAuth tokens are accepted on non-ai endpoints; the ai-* family is
17
+ * employee-only (see SKILL.md).
18
+ */
19
+
20
+ import { dirname, resolve } from 'path';
21
+ import { fileURLToPath } from 'url';
22
+ import dotenv from 'dotenv';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ dotenv.config({ path: resolve(__dirname, '../../../.env') });
26
+
27
+ const BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
28
+ const CLIENT_ID = 'dungeon-master';
29
+
30
+ const args = process.argv.slice(2);
31
+ const getMode = popFlag('--get');
32
+ const bearer = popOpt('--bearer') ?? process.env.BEARER_TOKEN;
33
+ const region = popOpt('--region') ?? 'US';
34
+ const [pathArg, bodyArg] = args;
35
+
36
+ if (!pathArg || !pathArg.startsWith('/')) {
37
+ console.error('Usage: node pt.mjs <path starting with /> [\'<json-body>\'] [--bearer <token>] [--get] [--region US]');
38
+ process.exit(1);
39
+ }
40
+
41
+ if (getMode) {
42
+ const res = await fetch(BASE + pathArg);
43
+ console.log(JSON.stringify(await res.json(), null, 2));
44
+ process.exit(res.ok ? 0 : 1);
45
+ }
46
+
47
+ if (!bearer) {
48
+ console.error('No bearer token: pass --bearer or set BEARER_TOKEN in .env');
49
+ process.exit(1);
50
+ }
51
+
52
+ let body = {};
53
+ if (bodyArg) {
54
+ try {
55
+ body = JSON.parse(bodyArg);
56
+ } catch (err) {
57
+ console.error(`Body is not valid JSON: ${err.message}`);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ const res = await fetch(BASE + pathArg, {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
65
+ body: JSON.stringify({ client_id: CLIENT_ID, region, ...body }),
66
+ });
67
+
68
+ const text = await res.text();
69
+ try {
70
+ console.log(JSON.stringify(JSON.parse(text), null, 2));
71
+ } catch {
72
+ console.log(text);
73
+ }
74
+ if (!res.ok) {
75
+ console.error(`\nHTTP ${res.status} ${res.statusText}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ function popFlag(name) {
80
+ const i = args.indexOf(name);
81
+ if (i === -1) return false;
82
+ args.splice(i, 1);
83
+ return true;
84
+ }
85
+
86
+ function popOpt(name) {
87
+ const i = args.indexOf(name);
88
+ if (i === -1) return undefined;
89
+ const [, value] = args.splice(i, 2);
90
+ return value;
91
+ }
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * snapshot-project.mjs — export a Mixpanel project's schema + relative event
5
+ * volumes into one normalized JSON snapshot, for authoring a synthetic
6
+ * dungeon-master "copy" of the project.
7
+ *
8
+ * Usage:
9
+ * node .claude/skills/powertools/snapshot-project.mjs <project_id> --bearer <token> \
10
+ * [--region US|EU|IN] [--out <file>]
11
+ *
12
+ * Uses the Power Tools API: /macro/get-schema (include_metadata + verbose) +
13
+ * /query/getTopEvents. The bearer token (customer or employee OAuth) needs
14
+ * data access to the project — /auth reporting accessible: true is not enough.
15
+ *
16
+ * Output shape (events sorted by count desc):
17
+ * {
18
+ * projectId, projectName, fetchedAt, region, totalCount,
19
+ * events: [{ name, count, pct, properties: [{ name, type, description }] }],
20
+ * userProps: [{ name, type, description }],
21
+ * groups: {}
22
+ * }
23
+ *
24
+ * Privacy: captures schema + volumes ONLY — never property values.
25
+ */
26
+
27
+ import { writeFileSync } from 'fs';
28
+ import { dirname, resolve } from 'path';
29
+ import { fileURLToPath } from 'url';
30
+ import dotenv from 'dotenv';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ dotenv.config({ path: resolve(__dirname, '../../../.env') });
34
+
35
+ const PT_BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
36
+ const CLIENT_ID = 'dungeon-master';
37
+ const REGIONS = ['US', 'EU', 'IN'];
38
+
39
+ const args = process.argv.slice(2);
40
+ const bearer = popOpt('--bearer') ?? process.env.BEARER_TOKEN;
41
+ const region = (popOpt('--region') ?? 'US').toUpperCase();
42
+ const outArg = popOpt('--out');
43
+ const projectId = args.find((a) => !a.startsWith('--'));
44
+
45
+ if (!projectId || !/^\d+$/.test(projectId)) fail('Usage: snapshot-project.mjs <project_id> --bearer <token> [--region US] [--out file]');
46
+ if (!bearer) fail('No bearer token: pass --bearer or set BEARER_TOKEN in .env');
47
+ if (!REGIONS.includes(region)) fail(`Unknown --region "${region}"`);
48
+
49
+ const outPath = resolve(process.cwd(), outArg ?? `snapshot-${projectId}.json`);
50
+
51
+ const snapshot = await snapshotViaPowertools();
52
+
53
+ snapshot.events.sort((a, b) => b.count - a.count);
54
+ snapshot.totalCount = snapshot.events.reduce((s, e) => s + e.count, 0);
55
+ for (const e of snapshot.events) e.pct = snapshot.totalCount ? +(e.count / snapshot.totalCount * 100).toFixed(4) : 0;
56
+
57
+ writeFileSync(outPath, JSON.stringify(snapshot, null, 2));
58
+
59
+ const zeros = snapshot.events.filter((e) => e.count === 0).length;
60
+ console.log(`✓ snapshot → ${outPath}`);
61
+ console.log(` project: ${snapshot.projectName ?? '(name unknown)'} (${snapshot.projectId})`);
62
+ console.log(` events: ${snapshot.events.length} (${zeros} with zero volume)`);
63
+ console.log(` userProps: ${snapshot.userProps.length}`);
64
+ console.log(` total vol: ${snapshot.totalCount.toLocaleString()} events`);
65
+ console.log(' top 10:');
66
+ for (const e of snapshot.events.slice(0, 10)) console.log(` ${e.pct.toFixed(2).padStart(6)}% ${e.count.toLocaleString().padStart(12)} ${e.name}`);
67
+
68
+ async function snapshotViaPowertools() {
69
+ const schema = await ptPost('/macro/get-schema', { project_id: projectId, include_metadata: true, verbose: true });
70
+ const top = await ptPost('/query/getTopEvents', { project_id: projectId, limit: 500 });
71
+
72
+ const counts = {};
73
+ for (const r of top.results ?? []) counts[r.event] = r.count;
74
+
75
+ // dependencies.events maps eventName → [propertyNames]; property defs live in schema.properties
76
+ const propDefs = new Map();
77
+ for (const p of schema.properties ?? []) propDefs.set(p.name, p);
78
+ const deps = schema.dependencies?.events ?? {};
79
+
80
+ const events = (schema.events ?? []).map((ev) => {
81
+ const name = ev.name ?? ev;
82
+ const propNames = deps[name] ?? [];
83
+ return {
84
+ name,
85
+ count: counts[name] ?? 0,
86
+ pct: 0,
87
+ properties: propNames.map((pn) => {
88
+ const def = propDefs.get(pn) ?? {};
89
+ return { name: pn, type: def.type ?? 'string', description: def.description ?? '' };
90
+ }),
91
+ };
92
+ });
93
+
94
+ const userProps = (schema.users ?? []).map((p) => ({
95
+ name: p.name ?? p,
96
+ type: p.type ?? 'string',
97
+ description: p.description ?? '',
98
+ }));
99
+
100
+ return { projectId, projectName: null, fetchedAt: new Date().toISOString(), region, totalCount: 0, events, userProps, groups: schema.groups ?? {} };
101
+ }
102
+
103
+ async function ptPost(pathname, body) {
104
+ const res = await fetch(PT_BASE + pathname, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
107
+ body: JSON.stringify({ client_id: CLIENT_ID, region, ...body }),
108
+ });
109
+ const json = await res.json().catch(() => ({}));
110
+ if (!res.ok) throw new Error(`POST ${pathname} → HTTP ${res.status}: ${json.error ?? JSON.stringify(json).slice(0, 300)}`);
111
+ return json;
112
+ }
113
+
114
+ function popOpt(name) {
115
+ const i = args.indexOf(name);
116
+ if (i === -1) return undefined;
117
+ const [, value] = args.splice(i, 2);
118
+ return value;
119
+ }
120
+
121
+ function fail(msg) {
122
+ console.error(`✖ ${msg}`);
123
+ process.exit(1);
124
+ }