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

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 (36) hide show
  1. package/.claude/skills/powertools/SKILL.md +75 -0
  2. package/.claude/skills/powertools/pt.mjs +91 -0
  3. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  4. package/CHANGELOG.md +138 -0
  5. package/README.md +2 -0
  6. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +4 -5
  7. package/dungeons/vertical/community/community.verify.mjs +4 -6
  8. package/dungeons/vertical/crypto/crypto.verify.mjs +4 -2
  9. package/dungeons/vertical/dating/dating.verify.mjs +4 -6
  10. package/dungeons/vertical/devtools/devtools.verify.mjs +4 -2
  11. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +4 -4
  12. package/dungeons/vertical/education/education.verify.mjs +4 -9
  13. package/dungeons/vertical/fintech/fintech.verify.mjs +4 -4
  14. package/dungeons/vertical/fitness/fitness.verify.mjs +4 -5
  15. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +4 -2
  16. package/dungeons/vertical/gaming/gaming.verify.mjs +4 -4
  17. package/dungeons/vertical/healthcare/healthcare.verify.mjs +4 -6
  18. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +4 -2
  19. package/dungeons/vertical/logistics/logistics.verify.mjs +4 -7
  20. package/dungeons/vertical/marketplace/marketplace.verify.mjs +4 -2
  21. package/dungeons/vertical/media/media.verify.mjs +4 -2
  22. package/dungeons/vertical/real-estate/real-estate.verify.mjs +4 -9
  23. package/dungeons/vertical/sass/sass.verify.mjs +4 -2
  24. package/dungeons/vertical/social/social.verify.mjs +4 -2
  25. package/dungeons/vertical/streaming/streaming.verify.mjs +4 -2
  26. package/dungeons/vertical/support-desk/support-desk.verify.mjs +4 -2
  27. package/dungeons/vertical/travel/travel.verify.mjs +4 -6
  28. package/index.js +35 -2
  29. package/lib/core/config-validator.js +19 -1
  30. package/lib/orchestrators/mixpanel-sender.js +52 -2
  31. package/lib/utils/utils.js +146 -55
  32. package/lib/verify/index.js +6 -0
  33. package/lib/verify/verify-dungeon.js +39 -12
  34. package/package.json +4 -4
  35. package/scripts/verify-stories.mjs +2 -1
  36. package/types.d.ts +17 -0
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './fitness.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-fitness';
@@ -51,12 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the H9 stories carry their own explicit
55
- // conversion window (48h generative window × the 1.25 max stretch factor,
56
- // covering the stretched support), so no funnel-default threading is needed
57
54
  const results = await evaluateStories(stories, events, {
58
55
  profiles,
59
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
60
59
  identityMap: buildIdentityMap(profiles),
61
60
  runSql,
62
61
  });
@@ -18,7 +18,7 @@ import path from 'node:path';
18
18
  import readline from 'node:readline';
19
19
  import { execFile } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
22
22
  import config, { stories } from './food-delivery.js';
23
23
 
24
24
  const PREFIX = process.argv[2] || 'verify-food-delivery';
@@ -60,7 +60,9 @@ const runSql = async (sql) => {
60
60
  // confounds a raw emulator read would inherit.
61
61
  const results = await evaluateStories(stories, events, {
62
62
  profiles,
63
- funnels: config.funnels,
63
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
64
+ // config — the dungeon was not run in this process, so validate here.
65
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
64
66
  identityMap: buildIdentityMap(profiles),
65
67
  runSql,
66
68
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './gaming.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-gaming';
@@ -51,11 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — every timeToConvert story carries an
55
- // explicit conversionWindowMs, so no funnel-default threading is needed
56
54
  const results = await evaluateStories(stories, events, {
57
55
  profiles,
58
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
59
59
  identityMap: buildIdentityMap(profiles),
60
60
  runSql,
61
61
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './healthcare.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-healthcare';
@@ -51,13 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the H8/H9 stories carry their own
55
- // explicit conversion windows (derived from the funnel's generative window
56
- // × the max H9 stretch factor, covering the stretched support), so no
57
- // funnel-default threading is needed
58
54
  const results = await evaluateStories(stories, events, {
59
55
  profiles,
60
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
61
59
  identityMap: buildIdentityMap(profiles),
62
60
  runSql,
63
61
  });
@@ -18,7 +18,7 @@ import path from 'node:path';
18
18
  import readline from 'node:readline';
19
19
  import { execFile } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
22
22
  import config, { stories } from './insurance-application.js';
23
23
 
24
24
  const PREFIX = process.argv[2] || 'verify-insurance-application';
@@ -59,7 +59,9 @@ const runSql = async (sql) => {
59
59
  // because H5 pins approvals to firstStart + target + [0,4h) by construction.
60
60
  const results = await evaluateStories(stories, events, {
61
61
  profiles,
62
- funnels: config.funnels,
62
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
63
+ // config — the dungeon was not run in this process, so validate here.
64
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
63
65
  identityMap: buildIdentityMap(profiles),
64
66
  runSql,
65
67
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './logistics.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-logistics';
@@ -51,14 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the emulator stories carry their own
55
- // explicit conversion windows (H8: 48h Integration Setup treated arm, 336h
56
- // Supplier Management placebo; H10: 93.6h = 72h Onboarding generative
57
- // window × the 1.3 small-business stretch, covering the stretched support),
58
- // so no funnel-default threading is needed
59
54
  const results = await evaluateStories(stories, events, {
60
55
  profiles,
61
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
62
59
  identityMap: buildIdentityMap(profiles),
63
60
  runSql,
64
61
  });
@@ -18,7 +18,7 @@ import path from 'node:path';
18
18
  import readline from 'node:readline';
19
19
  import { execFile } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
22
22
  import config, { stories } from './marketplace.js';
23
23
 
24
24
  const PREFIX = process.argv[2] || 'verify-marketplace';
@@ -59,7 +59,9 @@ const runSql = async (sql) => {
59
59
  // windows admit cross-instance chains that contaminate the fast tail.
60
60
  const results = await evaluateStories(stories, events, {
61
61
  profiles,
62
- funnels: config.funnels,
62
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
63
+ // config — the dungeon was not run in this process, so validate here.
64
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
63
65
  identityMap: buildIdentityMap(profiles),
64
66
  runSql,
65
67
  });
@@ -18,7 +18,7 @@ import path from 'node:path';
18
18
  import readline from 'node:readline';
19
19
  import { execFile } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
22
22
  import config, { stories } from './media.js';
23
23
 
24
24
  const PREFIX = process.argv[2] || 'verify-media';
@@ -59,7 +59,9 @@ const runSql = async (sql) => {
59
59
  // vs cross-session) and flips on sampling noise.
60
60
  const results = await evaluateStories(stories, events, {
61
61
  profiles,
62
- funnels: config.funnels,
62
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
63
+ // config — the dungeon was not run in this process, so validate here.
64
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
63
65
  identityMap: buildIdentityMap(profiles),
64
66
  runSql,
65
67
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './real-estate.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-real-estate';
@@ -51,16 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — H10's emulator stories carry their own
55
- // conversion window: 31.2h = 24h generative Tour Funnel TTC × the 1.3
56
- // Standard-tier stretch, so the stretched support is fully covered. The
57
- // PRIMARY read is the 2-step view→tour-scheduled pair; the doc's 3-step read
58
- // (…→offer submitted) stays as a directional secondary because H4/H6 offer
59
- // clones at random timestamps collide with the greedy third-step pick and
60
- // attenuate the ratio toward 1 (see the H10 story narrative).
61
54
  const results = await evaluateStories(stories, events, {
62
55
  profiles,
63
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
64
59
  identityMap: buildIdentityMap(profiles),
65
60
  runSql,
66
61
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './sass.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-sass';
@@ -59,7 +59,9 @@ const runSql = async (sql) => {
59
59
  // a slow organic tail that dilutes the segment medians.
60
60
  const results = await evaluateStories(stories, events, {
61
61
  profiles,
62
- funnels: config.funnels,
62
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
63
+ // config — the dungeon was not run in this process, so validate here.
64
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
63
65
  identityMap: buildIdentityMap(profiles),
64
66
  runSql,
65
67
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './social.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-social';
@@ -61,7 +61,9 @@ const runSql = async (sql) => {
61
61
  // across 1h-24h windows.
62
62
  const results = await evaluateStories(stories, events, {
63
63
  profiles,
64
- funnels: config.funnels,
64
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
65
+ // config — the dungeon was not run in this process, so validate here.
66
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
65
67
  identityMap: buildIdentityMap(profiles),
66
68
  runSql,
67
69
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './streaming.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-streaming';
@@ -60,7 +60,9 @@ const runSql = async (sql) => {
60
60
  // campaign cohort without needing hashFloat in SQL.
61
61
  const results = await evaluateStories(stories, events, {
62
62
  profiles,
63
- funnels: config.funnels,
63
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
64
+ // config — the dungeon was not run in this process, so validate here.
65
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
64
66
  identityMap: buildIdentityMap(profiles),
65
67
  runSql,
66
68
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './support-desk.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-support-desk';
@@ -60,7 +60,9 @@ const runSql = async (sql) => {
60
60
  // > 30 min) and split by role without needing hashFloat.
61
61
  const results = await evaluateStories(stories, events, {
62
62
  profiles,
63
- funnels: config.funnels,
63
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
64
+ // config — the dungeon was not run in this process, so validate here.
65
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
64
66
  identityMap: buildIdentityMap(profiles),
65
67
  runSql,
66
68
  });
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import readline from 'node:readline';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
20
+ import { buildIdentityMap, evaluateStories, validateDungeonConfig, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
21
  import config, { stories } from './travel.js';
22
22
 
23
23
  const PREFIX = process.argv[2] || 'verify-travel';
@@ -51,13 +51,11 @@ const runSql = async (sql) => {
51
51
  return stdout.trim() ? JSON.parse(stdout) : [];
52
52
  };
53
53
 
54
- // funnels passed raw (unvalidated) — the H8/H9 emulator stories carry their
55
- // own explicit conversion windows (72h Onboarding; 48h Search to Book
56
- // generative window × the 1.25 budget/leisure stretch = 60h, covering the
57
- // stretched support), so no funnel-default threading is needed
58
54
  const results = await evaluateStories(stories, events, {
59
55
  profiles,
60
- funnels: config.funnels,
56
+ // funnel defaults (conversionWindowDays, order) resolve on the VALIDATED
57
+ // config — the dungeon was not run in this process, so validate here.
58
+ funnels: validateDungeonConfig({ ...config, token: '' }).funnels,
61
59
  identityMap: buildIdentityMap(profiles),
62
60
  runSql,
63
61
  });
package/index.js CHANGED
@@ -20,14 +20,14 @@ import { extractComments } from './lib/core/extract-comments.js';
20
20
 
21
21
  // Orchestrators
22
22
  import { userLoop } from './lib/orchestrators/user-loop.js';
23
- import { sendToMixpanel, collectWrittenFiles } from './lib/orchestrators/mixpanel-sender.js';
23
+ import { sendToMixpanel, collectWrittenFiles, releaseConnections } from './lib/orchestrators/mixpanel-sender.js';
24
24
  // Generators
25
25
  import { makeAdSpend } from './lib/generators/adspend.js';
26
26
  import { makeMirror } from './lib/generators/mirror.js';
27
27
  import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
28
28
 
29
29
  // Utilities
30
- import { initChance, initUserChance, resetUserChance, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
30
+ import { initChance, initUserChance, resetUserChance, resetValueCaches, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
31
31
  import { runWithDataset } from './lib/utils/dataset-context.js';
32
32
 
33
33
  // External dependencies
@@ -130,6 +130,10 @@ async function runDungeon(config) {
130
130
  // Initialize seeded RNG BEFORE validation — config-validator captures a
131
131
  // chance reference for default userProps (spiritAnimal). If we init after,
132
132
  // run 1 binds an unseeded instance while run 2 binds a stale one → non-deterministic.
133
+ // v1.6.1: clear per-run winner/weighted-array caches unconditionally —
134
+ // a prior in-process run must never leak its winners into this one.
135
+ // (initChance also clears them, but only fires when a seed is set.)
136
+ resetValueCaches();
133
137
  if (config.seed) {
134
138
  initChance(config.seed);
135
139
  }
@@ -287,6 +291,12 @@ async function runDungeon(config) {
287
291
  eventCount: context.getStoredEventCount(),
288
292
  userCount: context.getUserCount(),
289
293
  profilesPushed,
294
+ // v1.6.2: the enriched config this run actually used. `validateDungeonConfig`
295
+ // no longer writes back to the caller's object, so consumers that need
296
+ // resolved values (`funnels[].conversionWindowDays`, `events[].isStrictEvent`,
297
+ // the resolved dataset window) read them here. `verifyDungeon` depends on this.
298
+ // Credentials are stripped — a Result is a thing hosts log.
299
+ validatedConfig: redactCredentials(validatedConfig),
290
300
  ...(progressSummary.updates > 0 || progressSummary.errors > 0 ? { progress: progressSummary } : {})
291
301
  };
292
302
 
@@ -556,6 +566,26 @@ async function flushStorageToDisk(storage, config) {
556
566
  }
557
567
  }
558
568
 
569
+ /**
570
+ * Shallow copy of the validated config with every credential removed, for the
571
+ * `validatedConfig` field on a Result.
572
+ *
573
+ * A Result is a thing hosts log, serialize, and attach to CI artifacts. Before
574
+ * v1.6.2 it carried no credentials at all, and surfacing the resolved config
575
+ * shouldn't quietly change that: the run object holds `token`, `serviceAccount`,
576
+ * `serviceSecret` and `projectId` both flattened and under `credentials`.
577
+ * @param {import('./types').Dungeon} validated
578
+ * @returns {import('./types').Dungeon}
579
+ */
580
+ function redactCredentials(validated) {
581
+ if (!validated || typeof validated !== 'object') return validated;
582
+ const safe = { ...validated };
583
+ for (const key of ['token', 'serviceAccount', 'serviceSecret', 'projectId', 'credentials']) {
584
+ delete safe[key];
585
+ }
586
+ return safe;
587
+ }
588
+
559
589
  /**
560
590
  * Extract file information from storage containers
561
591
  * @param {import('./types').Storage} storage - Storage object
@@ -600,4 +630,7 @@ function extractStorageData(storage) {
600
630
  // ES Module exports
601
631
  export default DUNGEON_MASTER;
602
632
  export { parseJSONDungeon, validateDungeonShape, loadFromFile, loadFromText, dungeonToJSON, extractComments };
633
+ // v1.6.2: pool teardown is automatic when the last import settles; exported for hosts
634
+ // that drive `mixpanel-import` themselves and want the same cleanup.
635
+ export { releaseConnections };
603
636
 
@@ -407,13 +407,31 @@ function resolveDevicesPerUser(config) {
407
407
  }
408
408
 
409
409
  /**
410
- * Validates and enriches a dungeon configuration object
410
+ * Validates and enriches a dungeon configuration object.
411
+ *
412
+ * **Does not mutate its input** (as of v1.6.2). Enrichment lands only on the
413
+ * returned object. Callers that need the enriched `events` / `funnels` must read
414
+ * the return value — reading them back off the object they passed in worked
415
+ * before v1.6.2 and no longer does.
416
+ *
411
417
  * @param {Partial<Dungeon>} config - Raw configuration object
412
418
  * @returns {Dungeon} Validated and enriched configuration
413
419
  */
414
420
  export function validateDungeonConfig(config) {
415
421
  const chance = u.getChance();
416
422
 
423
+ // v1.6.2: own our input. Enrichment below writes through to the caller's
424
+ // nested objects otherwise — `isStrictEvent` onto `events[i]`,
425
+ // `conversionWindowDays` / `_experiment` onto `funnels[j]`. For a dungeon
426
+ // loaded from a file those arrays belong to the ESM module cache, so a second
427
+ // run in the same process was handed a config already enriched by the first:
428
+ // every event pre-promoted to strict, the catch-all funnel swept nothing, and
429
+ // event volume collapsed. Enrichment is also not idempotent — re-validating an
430
+ // already-validated config rebuilds the funnel set incorrectly.
431
+ // deepClone keeps functions by reference, so `hook`, `onProgress` and
432
+ // chance-bound prop thunks survive.
433
+ config = u.deepClone(config);
434
+
417
435
  // v1.5.1 (TODO #8): merge `switches` / `identity` / `credentials`
418
436
  // sub-objects into top-level keys. New shape is preferred; old top-level
419
437
  // keys still honored for back-compat with a verbose-gated warning. The
@@ -10,14 +10,64 @@ import { comma, rm } from "ak-tools";
10
10
  import * as u from "../utils/utils.js";
11
11
  import mp from "mixpanel-import";
12
12
 
13
+ export { collectWrittenFiles };
14
+
15
+ /**
16
+ * Count of `sendToMixpanel` calls currently in flight in this process.
17
+ *
18
+ * `mp.destroy()` tears down mixpanel-import's PROCESS-GLOBAL undici pools, so a
19
+ * run that finishes while another is still importing would close the sockets out
20
+ * from under it — surfacing as `UND_ERR_CLOSED`, which mixpanel-import does not
21
+ * retry. Only the last run out turns off the lights.
22
+ */
23
+ let _inFlightImports = 0;
24
+
25
+ /**
26
+ * Release mixpanel-import's shared undici connection pools, if no import is still
27
+ * running. dungeon-master is a library — a host process that runs occasional
28
+ * imports shouldn't hold ingest sockets open in between. Pools are lazily
29
+ * re-created, so a later run is unaffected.
30
+ *
31
+ * Called automatically when the last in-flight import settles; exported so a host
32
+ * that drives `mixpanel-import` directly can force the same cleanup. Note the
33
+ * refcount only sees imports that go through `sendToMixpanel` — a host running its
34
+ * own `mixpanel-import` job concurrently with a dungeon run can still have its
35
+ * pools closed underneath it.
36
+ * @returns {Promise<boolean>} true if pools were torn down.
37
+ */
38
+ export async function releaseConnections() {
39
+ // `destroy` only exists on mixpanel-import >= 3.5.1.
40
+ if (_inFlightImports > 0 || typeof mp.destroy !== 'function') return false;
41
+ try {
42
+ await mp.destroy();
43
+ return true;
44
+ } catch (err) {
45
+ // Never fail a completed import over socket cleanup.
46
+ log(` !! connection pool cleanup failed: ${err.message}\n`);
47
+ return false;
48
+ }
49
+ }
50
+
13
51
  /**
14
52
  * Sends the data to Mixpanel
15
53
  * @param {Context} context - Context object containing config, storage, etc.
16
54
  * @returns {Promise<Object>} Import results for all data types
17
55
  */
18
- export { collectWrittenFiles };
19
-
20
56
  export async function sendToMixpanel(context) {
57
+ _inFlightImports++;
58
+ try {
59
+ return await _sendToMixpanel(context);
60
+ } finally {
61
+ _inFlightImports--;
62
+ await releaseConnections();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * @param {Context} context
68
+ * @returns {Promise<Object>}
69
+ */
70
+ async function _sendToMixpanel(context) {
21
71
  const { config, storage } = context;
22
72
  const {
23
73
  adSpendData,