@ak--47/dungeon-master 1.5.3 → 1.6.0

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 (146) 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 +96 -0
  4. package/.claude/skills/create-project/provision.mjs +336 -0
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +211 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +3 -2
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. 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` +
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: create-project
3
+ description: Use when an existing dungeon needs a real Mixpanel project provisioned before sending data — creates the project, sets timezone UTC, mints a scoped service account, adds the dungeon's group keys, uploads business context (AI context), and writes the resulting credentials back into the dungeon so it "just runs". Follows create-dungeon / write-hooks / verify-dungeon.
4
+ argument-hint: [dungeon path, e.g. dungeons/user/shopstream/shopstream.js]
5
+ model: claude-opus-4-6
6
+ effort: max
7
+ ---
8
+
9
+ # Create a Mixpanel Project for a Dungeon
10
+
11
+ Provision a fresh Mixpanel project for an existing dungeon and wire its credentials back in.
12
+
13
+ **Dungeon file:** `$ARGUMENTS`
14
+
15
+ This is the step after a dungeon is authored, reviewed, and tweaked. It turns a
16
+ local dungeon into one you can actually send to Mixpanel by creating the project
17
+ and stamping `credentials` back into the file.
18
+
19
+ ## What it does
20
+
21
+ All work runs through the orchestrator `provision.mjs` (this skill's directory),
22
+ which calls the [power-tools API](https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app)
23
+ in order:
24
+
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
+ 2. **mintServiceAccount** — `admin`, expires `+30 days`, scoped to the new project. This is what the dungeon uses to **send** data.
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` 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
+ 5. **write-back** — replaces the dungeon's `credentials: { … }` block with `{ token, projectId, serviceAccount, serviceSecret, region }`.
30
+
31
+ **Always creates a fresh project.** Re-running mints a new project and overwrites
32
+ `credentials`. Auth for all four calls is the OAuth `BEARER_TOKEN`; the minted
33
+ service account is only written into the dungeon for later data sends.
34
+
35
+ ## Prerequisites
36
+
37
+ `.env` at the repo root must contain (both are gitignored):
38
+
39
+ ```
40
+ BEARER_TOKEN=<oauth token> # creates projects / mints SAs / adds group keys
41
+ ORG_ID=<organization id>
42
+ ```
43
+
44
+ If either is missing the orchestrator exits with a clear message — tell the user
45
+ to add them to `.env`.
46
+
47
+ User dungeons (`dungeons/user/`, `dungeons/customers/`, `dungeons/capstone/`) are
48
+ gitignored, so writing plaintext credentials into them is expected and safe.
49
+
50
+ ## Steps
51
+
52
+ ### 1. Show the plan (dry run)
53
+
54
+ ```bash
55
+ node .claude/skills/create-project/provision.mjs <dungeon-path> --dry-run
56
+ ```
57
+
58
+ This makes **no** API calls. It prints the derived project name, group-key
59
+ mapping, service-account name/expiry, and a business-context preview. Show this
60
+ to the user.
61
+
62
+ ### 2. Confirm
63
+
64
+ Creating a real project + service account is outward-facing and not easily
65
+ undone. Confirm with the user before the live run (one line is enough).
66
+
67
+ ### 3. Provision (live)
68
+
69
+ ```bash
70
+ node .claude/skills/create-project/provision.mjs <dungeon-path>
71
+ ```
72
+
73
+ On success it writes `credentials` back into the dungeon and prints a non-secret
74
+ summary (project URL + id, SA username/expiry, group keys added/skipped, context
75
+ size). Token and secret are written into the dungeon, not printed.
76
+
77
+ ### 4. Report
78
+
79
+ Relay the project URL and confirm credentials were written. Point the user at the
80
+ run command:
81
+
82
+ ```bash
83
+ node scripts/run-dungeon.mjs <dungeon-path>
84
+ ```
85
+
86
+ ## Error handling
87
+
88
+ - **Missing `BEARER_TOKEN` / `ORG_ID`** — orchestrator exits; have the user fix `.env`.
89
+ - **`createProject` fails** — nothing is provisioned; surface the power-tools error (`{ error }` or `{ errors:[{param,message}] }`) verbatim and stop.
90
+ - **A later step fails** (mint / group keys / context / write-back) — the orchestrator continues, writes back whatever succeeded, and lists the failed step under `⚠ warnings`. Relay those warnings; the user may re-run or fix manually.
91
+
92
+ ## Notes
93
+
94
+ - Region is always `US`; timezone is always `UTC` (matches the dungeon's UTC time window so Mixpanel day-bucketing aligns).
95
+ - Group keys come straight from the dungeon's `groupKeys` (`[["parent_account", N, []], …]`) — author them there (via `create-dungeon`) before running this skill.
96
+ - The orchestrator reuses the package's own exports (`loadFromFile`, `extractComments`) — no separate parser to keep in sync.
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * create-project orchestrator.
5
+ *
6
+ * Given an existing dungeon file, provisions a fresh Mixpanel project via the
7
+ * power-tools API and writes the resulting credentials back into the dungeon so
8
+ * it can "just run":
9
+ *
10
+ * 1. createProject (sets timezone UTC as a follow-up)
11
+ * 2. mintServiceAccount (admin, +30d) — scoped to the new project
12
+ * 3. addGroupKey (only if the dungeon declares groupKeys)
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)
16
+ * 5. write `credentials: { token, projectId, serviceAccount, serviceSecret, region }`
17
+ * back into the dungeon (gitignored user dungeons — plaintext is fine)
18
+ *
19
+ * Auth: all calls use the OAuth BEARER_TOKEN from .env. The minted service
20
+ * account is for the dungeon to SEND data later, not for setup.
21
+ *
22
+ * Usage:
23
+ * node .claude/skills/create-project/provision.mjs <dungeon-path> [--dry-run]
24
+ *
25
+ * Env (.env at repo root):
26
+ * BEARER_TOKEN=<oauth token>
27
+ * ORG_ID=<organization id>
28
+ */
29
+
30
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
31
+ import path, { dirname, resolve } from 'path';
32
+ import { fileURLToPath, pathToFileURL } from 'url';
33
+ import dotenv from 'dotenv';
34
+ import { loadFromFile, extractComments } from '../../../index.js';
35
+
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ const REPO_ROOT = resolve(__dirname, '../../../');
38
+
39
+ dotenv.config({ path: resolve(REPO_ROOT, '.env') });
40
+
41
+ const BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
42
+ const CLIENT_ID = 'dungeon-master';
43
+ const REGION = 'US';
44
+ const SA_TTL_DAYS = 30;
45
+
46
+ // ── args ──────────────────────────────────────────────────────────────────
47
+ const args = process.argv.slice(2);
48
+ const dryRun = args.includes('--dry-run');
49
+ const dungeonArg = args.find((a) => !a.startsWith('--'));
50
+
51
+ if (!dungeonArg) {
52
+ fail('Usage: node .claude/skills/create-project/provision.mjs <dungeon-path> [--dry-run]');
53
+ }
54
+
55
+ const dungeonPath = resolve(process.cwd(), dungeonArg);
56
+ if (!existsSync(dungeonPath)) fail(`dungeon file not found: ${dungeonPath}`);
57
+
58
+ // ── main ──────────────────────────────────────────────────────────────────
59
+ const config = await loadFromFile(dungeonPath);
60
+ const comments = extractComments(dungeonPath);
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
+
75
+ const name = deriveName(comments, dungeonPath);
76
+ const groupKeys = Array.isArray(config.groupKeys)
77
+ ? config.groupKeys.filter(Boolean).map(([prop]) => ({ property_name: prop, display_name: titleize(prop) }))
78
+ : [];
79
+ const saName = `${slug(name)}-dungeon-sa`.slice(0, 64);
80
+ const ctxSource = stories ? `stories export (${stories.length} stories)` : 'comment scrape';
81
+ const content = buildContext(name, config, comments, groupKeys, stories);
82
+
83
+ if (dryRun) {
84
+ printPlan();
85
+ process.exit(0);
86
+ }
87
+
88
+ // Live run needs auth.
89
+ const { BEARER_TOKEN, ORG_ID } = process.env;
90
+ if (!BEARER_TOKEN) fail('BEARER_TOKEN missing from .env (OAuth token required to create projects).');
91
+ if (!ORG_ID) fail('ORG_ID missing from .env (organization id required to create projects).');
92
+
93
+ const warnings = [];
94
+
95
+ // 1. create project (timezone set to UTC as a follow-up by the endpoint)
96
+ let project;
97
+ try {
98
+ project = await post('/crud/createProject', { org_id: ORG_ID, name, timezone: 'UTC' });
99
+ } catch (err) {
100
+ fail(`createProject failed — nothing provisioned.\n ${err.message}`);
101
+ }
102
+ const projectId = String(project.id);
103
+ const projectToken = project.token;
104
+
105
+ // 2. mint service account (admin, +30d)
106
+ let sa = null;
107
+ const expires = isoInDays(SA_TTL_DAYS);
108
+ try {
109
+ sa = await post('/crud/mintServiceAccount', {
110
+ org_id: ORG_ID,
111
+ project_id: projectId,
112
+ name: saName,
113
+ role: 'admin',
114
+ expires,
115
+ });
116
+ } catch (err) {
117
+ warnings.push(`mintServiceAccount failed: ${err.message}`);
118
+ }
119
+
120
+ // 3. group keys (only if declared)
121
+ let groupKeyResult = null;
122
+ if (groupKeys.length) {
123
+ try {
124
+ groupKeyResult = await post('/crud/addGroupKey', { project_id: projectId, group_keys: groupKeys });
125
+ } catch (err) {
126
+ warnings.push(`addGroupKey failed: ${err.message}`);
127
+ }
128
+ }
129
+
130
+ // 4. business context
131
+ try {
132
+ await post('/crud/setBusinessContext', { project_id: projectId, content });
133
+ } catch (err) {
134
+ warnings.push(`setBusinessContext failed: ${err.message}`);
135
+ }
136
+
137
+ // 5. write credentials back into the dungeon
138
+ const creds = {
139
+ token: projectToken,
140
+ projectId,
141
+ serviceAccount: sa?.username || '',
142
+ serviceSecret: sa?.secret || '',
143
+ region: REGION,
144
+ };
145
+ let wroteBack = true;
146
+ try {
147
+ writeBackCredentials(dungeonPath, creds);
148
+ } catch (err) {
149
+ wroteBack = false;
150
+ warnings.push(`credentials write-back failed: ${err.message}`);
151
+ }
152
+
153
+ printSummary();
154
+
155
+ // ── helpers ─────────────────────────────────────────────────────────────────
156
+
157
+ async function post(pathname, body) {
158
+ const res = await fetch(BASE + pathname, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.BEARER_TOKEN}` },
161
+ body: JSON.stringify({ client_id: CLIENT_ID, region: REGION, ...body }),
162
+ });
163
+ const text = await res.text();
164
+ let json;
165
+ try {
166
+ json = JSON.parse(text);
167
+ } catch {
168
+ json = { raw: text };
169
+ }
170
+ if (!res.ok) {
171
+ const detail = Array.isArray(json.errors)
172
+ ? json.errors.map((e) => `${e.param}: ${e.message}`).join('; ')
173
+ : json.error || text || `HTTP ${res.status}`;
174
+ throw new Error(`${res.status} ${detail}`);
175
+ }
176
+ return json;
177
+ }
178
+
179
+ function deriveName(comments, p) {
180
+ const m = (comments.overview || '').match(/^NAME:\s*(.+)$/m);
181
+ if (m) return m[1].trim();
182
+ return path.basename(p).replace(/\.(js|mjs|json)$/i, '');
183
+ }
184
+
185
+ function titleize(s) {
186
+ return String(s)
187
+ .split(/[_\s-]+/)
188
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
189
+ .join(' ');
190
+ }
191
+
192
+ function slug(s) {
193
+ return String(s)
194
+ .toLowerCase()
195
+ .replace(/[^a-z0-9]+/g, '-')
196
+ .replace(/^-+|-+$/g, '');
197
+ }
198
+
199
+ function isoInDays(days) {
200
+ const ms = Date.now() + days * 24 * 60 * 60 * 1000;
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');
205
+ }
206
+
207
+ function buildContext(name, config, comments, groupKeys, stories) {
208
+ const parts = [`# ${name}`, ''];
209
+ if (comments.overview) parts.push(comments.overview, '');
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
+ }
236
+
237
+ parts.push('## Schema', '');
238
+ const events = config.events || [];
239
+ parts.push(`### Events (${events.length})`);
240
+ for (const e of events) {
241
+ const props = e.properties ? Object.keys(e.properties).join(', ') : '';
242
+ const weight = e.weight != null ? ` (weight ${e.weight})` : '';
243
+ parts.push(`- ${e.event}${weight}${props ? ` — ${props}` : ''}`);
244
+ }
245
+ parts.push('');
246
+
247
+ const funnels = config.funnels || [];
248
+ if (funnels.length) {
249
+ parts.push(`### Funnels (${funnels.length})`);
250
+ for (const f of funnels) {
251
+ const seq = (f.sequence || []).join(' → ');
252
+ const rate = f.conversionRate != null ? ` (${f.conversionRate}%)` : '';
253
+ parts.push(`- ${f.name || '(unnamed)'}: ${seq}${rate}`);
254
+ }
255
+ parts.push('');
256
+ }
257
+
258
+ if (groupKeys.length) {
259
+ parts.push('### Group keys', ...groupKeys.map((g) => `- ${g.property_name} (${g.display_name})`), '');
260
+ }
261
+
262
+ let md = parts.join('\n');
263
+ if (md.length > 50000) md = md.slice(0, 49900) + '\n\n…(truncated to 50,000 chars)';
264
+ return md;
265
+ }
266
+
267
+ function writeBackCredentials(p, creds) {
268
+ let src = readFileSync(p, 'utf-8');
269
+ const block =
270
+ `credentials: { token: ${q(creds.token)}, projectId: ${q(creds.projectId)}, ` +
271
+ `serviceAccount: ${q(creds.serviceAccount)}, serviceSecret: ${q(creds.serviceSecret)}, region: ${q(creds.region)} }`;
272
+
273
+ const existing = /credentials\s*:\s*\{[\s\S]*?\}/;
274
+ if (existing.test(src)) {
275
+ src = src.replace(existing, block);
276
+ } else {
277
+ const opener = /(const\s+\w+\s*=\s*\{)/;
278
+ if (opener.test(src)) {
279
+ src = src.replace(opener, `$1\n\t${block},`);
280
+ } else {
281
+ throw new Error('no `credentials` block or `const X = {` opener found — add a credentials block manually.');
282
+ }
283
+ }
284
+ writeFileSync(p, src, 'utf-8');
285
+ }
286
+
287
+ function q(v) {
288
+ return JSON.stringify(String(v ?? ''));
289
+ }
290
+
291
+ function printPlan() {
292
+ const preview = content.length > 600 ? content.slice(0, 600) + ' …' : content;
293
+ console.log('── create-project plan (dry run) ────────────────────────────');
294
+ console.log(`dungeon: ${path.relative(process.cwd(), dungeonPath)}`);
295
+ console.log(`project name: ${name}`);
296
+ console.log(`region: ${REGION} timezone: UTC`);
297
+ console.log(`service acct: ${saName} (role admin, expires +${SA_TTL_DAYS}d)`);
298
+ console.log(`group keys: ${groupKeys.length ? groupKeys.map((g) => `${g.property_name} → "${g.display_name}"`).join(', ') : '(none)'}`);
299
+ console.log(`business ctx: ${content.length} chars (source: ${ctxSource})`);
300
+ console.log('');
301
+ console.log('would POST: createProject → mintServiceAccount' + (groupKeys.length ? ' → addGroupKey' : '') + ' → setBusinessContext');
302
+ console.log('then write credentials back into the dungeon.');
303
+ console.log('');
304
+ console.log('── business context preview ─────────────────────────────────');
305
+ console.log(preview);
306
+ }
307
+
308
+ function printSummary() {
309
+ console.log('── create-project: provisioned ──────────────────────────────');
310
+ console.log(`project: ${name} (id ${projectId})`);
311
+ if (project.url) console.log(`url: ${project.url}`);
312
+ console.log(`region: ${REGION} timezone: UTC`);
313
+ console.log(`service acct: ${sa ? `${sa.username} (role ${sa.role}, expires ${sa.expires})` : '(FAILED — see warnings)'}`);
314
+ if (groupKeys.length) {
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)';
320
+ console.log(`group keys: added [${added}] skipped [${skipped}]`);
321
+ }
322
+ console.log(`business ctx: ${content.length} chars uploaded (source: ${ctxSource})`);
323
+ console.log(`credentials: ${wroteBack ? 'written back into dungeon ✓' : 'NOT written (see warnings)'}`);
324
+ if (warnings.length) {
325
+ console.log('');
326
+ console.log('⚠ warnings:');
327
+ for (const w of warnings) console.log(` - ${w}`);
328
+ }
329
+ console.log('');
330
+ console.log(`next: node scripts/run-dungeon.mjs ${path.relative(process.cwd(), dungeonPath)}`);
331
+ }
332
+
333
+ function fail(msg) {
334
+ console.error(`✖ ${msg}`);
335
+ process.exit(1);
336
+ }