@ak--47/dungeon-master 1.7.0 → 1.8.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 (37) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +49 -10
  3. package/.claude/skills/create-project/SKILL.md +22 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +18 -1
  7. package/.claude/skills/powertools/SKILL.md +20 -1
  8. package/.claude/skills/release-check/SKILL.md +99 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +71 -16
  10. package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
  11. package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
  12. package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
  13. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  14. package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
  15. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  16. package/.claude/skills/write-hooks/SKILL.md +33 -3
  17. package/CHANGELOG.md +142 -0
  18. package/HOOKS.md +105 -5
  19. package/README.md +228 -0
  20. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  21. package/dungeons/technical/warehouse.js +187 -0
  22. package/index.js +116 -2
  23. package/lib/core/config-validator.js +21 -0
  24. package/lib/core/dungeon-loader.js +1 -1
  25. package/lib/core/storage.js +51 -3
  26. package/lib/generators/standalone.js +248 -0
  27. package/lib/generators/warehouse.js +828 -0
  28. package/lib/orchestrators/mixpanel-sender.js +27 -2
  29. package/lib/orchestrators/user-loop.js +1 -0
  30. package/lib/templates/story-spec.schema.json +41 -16
  31. package/lib/utils/utils.js +37 -12
  32. package/lib/verify/index.js +1 -0
  33. package/lib/verify/story-runner.js +71 -8
  34. package/lib/verify/warehouse.js +683 -0
  35. package/package.json +4 -2
  36. package/scripts/verify-stories.mjs +150 -44
  37. package/types.d.ts +303 -4
@@ -71,6 +71,7 @@ async function _sendToMixpanel(context) {
71
71
  const { config, storage } = context;
72
72
  const {
73
73
  adSpendData,
74
+ standaloneEventData,
74
75
  eventData,
75
76
  groupProfilesData,
76
77
  scdTableData,
@@ -208,6 +209,29 @@ async function _sendToMixpanel(context) {
208
209
  importResults.adSpend = imported;
209
210
  }
210
211
 
212
+ // Import standalone (identity-less) metric snapshots — v1.8.0.
213
+ // Same shape as the ad-spend path: its own stream, imported as events.
214
+ // Gated on the config so a batch-mode run without `standaloneEvents` does
215
+ // not attempt an empty file read.
216
+ const hasStandaloneConfig = Array.isArray(config.standaloneEvents) && config.standaloneEvents.length > 0;
217
+ if (hasStandaloneConfig && (standaloneEventData?.length > 0 || isBATCH_MODE)) {
218
+ log(` Standalone Events`);
219
+ let standaloneToImport = u.deepClone(standaloneEventData);
220
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && standaloneEventData && standaloneEventData.length === 0);
221
+ if (shouldReadFromFiles && standaloneEventData?.getWrittenFiles) {
222
+ const files = standaloneEventData.getWrittenFiles();
223
+ if (files.length > 0) standaloneToImport = files;
224
+ }
225
+ const standaloneTotal = Array.isArray(standaloneToImport) ? standaloneToImport.length : 0;
226
+ const imported = await mp(creds, standaloneToImport, {
227
+ recordType: "event",
228
+ ...commonOpts,
229
+ progressCallback: makeProgressCallback(standaloneTotal),
230
+ });
231
+ log(` -> ${comma(imported.success)} standalone events sent\n`);
232
+ importResults.standalone = imported;
233
+ }
234
+
211
235
  // Import group profiles
212
236
  if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
213
237
  for (const groupEntity of groupProfilesData) {
@@ -403,11 +427,12 @@ function logProblems(problems) {
403
427
  */
404
428
  function collectWrittenFiles(storage) {
405
429
  const files = [];
430
+ if (storage.warehouseManifestFile) files.push(storage.warehouseManifestFile);
406
431
  for (const container of [storage.eventData, storage.userProfilesData, storage.adSpendData,
407
- storage.mirrorEventData, storage.groupEventData]) {
432
+ storage.standaloneEventData, storage.mirrorEventData, storage.groupEventData]) {
408
433
  if (container?.getWrittenFiles) files.push(...container.getWrittenFiles());
409
434
  }
410
- for (const arr of [storage.groupProfilesData, storage.scdTableData, storage.lookupTableData]) {
435
+ for (const arr of [storage.groupProfilesData, storage.scdTableData, storage.lookupTableData, storage.warehouseMetricData]) {
411
436
  if (Array.isArray(arr)) {
412
437
  for (const c of arr) {
413
438
  if (c?.getWrittenFiles) files.push(...c.getWrittenFiles());
@@ -978,6 +978,7 @@ export async function userLoop(context) {
978
978
  }
979
979
  }
980
980
 
981
+ context.warehouseAccumulator?.ingest(usersEvents);
981
982
  await eventData.hookPush(usersEvents, { profile });
982
983
  });
983
984
 
@@ -85,7 +85,6 @@
85
85
  "properties": {
86
86
  "where": {
87
87
  "type": "object",
88
- "minProperties": 1,
89
88
  "description": "Column → value (equality) or { op, value } comparison. All clauses must match (AND).",
90
89
  "additionalProperties": {
91
90
  "oneOf": [
@@ -123,23 +122,49 @@
123
122
  "properties": {
124
123
  "type": { "type": "string", "minLength": 1 }
125
124
  },
126
- "if": {
127
- "properties": { "type": { "const": "duckdb" } }
128
- },
129
- "then": {
130
- "required": ["type", "sql"],
131
- "properties": {
132
- "type": { "const": "duckdb" },
133
- "sql": {
134
- "type": "string",
135
- "minLength": 1,
136
- "description": "DuckDB SQL escape hatch, for bespoke shapes only. The runner shells out to the `duckdb` CLI (no npm dep) and substitutes the literal token {{PREFIX}} with the run's data prefix path (e.g. data/verify-<name>), so globs read read_json_auto('{{PREFIX}}-EVENTS*.json'). Result rows feed select/expect like emulator rows. Disk mode only — skipped (with a warning) under --in-memory."
125
+ "allOf": [
126
+ {
127
+ "if": {
128
+ "properties": { "type": { "const": "duckdb" } }
129
+ },
130
+ "then": {
131
+ "required": ["type", "sql"],
132
+ "properties": {
133
+ "type": { "const": "duckdb" },
134
+ "sql": {
135
+ "type": "string",
136
+ "minLength": 1,
137
+ "description": "DuckDB SQL escape hatch, for bespoke shapes only. The runner shells out to the `duckdb` CLI (no npm dep) and substitutes the literal token {{PREFIX}} with the run's data prefix path (e.g. data/verify-<name>), so globs read read_json_auto('{{PREFIX}}-EVENTS*.json'). Result rows feed select/expect like emulator rows. Disk mode only — skipped (with a warning) under --in-memory."
138
+ }
139
+ }
140
+ }
141
+ },
142
+ {
143
+ "if": {
144
+ "properties": { "type": { "const": "warehouse" } }
145
+ },
146
+ "then": {
147
+ "required": ["type", "table"],
148
+ "properties": {
149
+ "type": { "const": "warehouse" },
150
+ "table": { "type": "string", "minLength": 1 }
151
+ }
152
+ }
153
+ },
154
+ {
155
+ "if": {
156
+ "properties": { "type": { "const": "warehouse-stats" } }
157
+ },
158
+ "then": {
159
+ "required": ["type", "table"],
160
+ "properties": {
161
+ "type": { "const": "warehouse-stats" },
162
+ "table": { "type": "string", "minLength": 1 }
163
+ }
137
164
  }
138
165
  }
139
- },
140
- "else": {
141
- "description": "Anything other than 'duckdb' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
142
- }
166
+ ],
167
+ "description": "Anything other than 'duckdb', 'warehouse', or 'warehouse-stats' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
143
168
  },
144
169
  "expect": {
145
170
  "type": "object",
@@ -815,10 +815,26 @@ function streamJSON(filePath, data, options = {}) {
815
815
  });
816
816
  }
817
817
 
818
+ function csvRow(item, columns) {
819
+ return columns.map(col => {
820
+ const value = item[col];
821
+
822
+ if (value === null || value === undefined) {
823
+ return '';
824
+ }
825
+
826
+ const serialized = typeof value === 'object'
827
+ ? JSON.stringify(value)
828
+ : value.toString();
829
+
830
+ return `"${serialized.replace(/"/g, '""')}"`;
831
+ }).join(',');
832
+ }
833
+
818
834
  function streamCSV(filePath, data, options = {}) {
819
835
  return new Promise((resolve, reject) => {
820
836
  let writeStream;
821
- const { gzip = false } = options;
837
+ const { gzip = false, fixedColumns } = options;
822
838
 
823
839
  if (filePath?.startsWith('gs://')) {
824
840
  const { uri, bucket, file } = parseGCSUri(filePath);
@@ -842,20 +858,12 @@ function streamCSV(filePath, data, options = {}) {
842
858
  }
843
859
  }
844
860
 
845
- // Extract all unique keys from the data array
846
- const columns = getUniqueKeys(data); // Assuming getUniqueKeys properly retrieves all keys
861
+ const columns = Array.isArray(fixedColumns) ? fixedColumns : getUniqueKeys(data);
847
862
 
848
- // Stream the header
849
863
  writeStream.write(columns.join(',') + '\n');
850
864
 
851
- // Stream each data row
852
865
  data.forEach(item => {
853
- for (const key in item) {
854
- // Ensure all nested objects are properly stringified
855
- if (typeof item[key] === "object") item[key] = JSON.stringify(item[key]);
856
- }
857
- const row = columns.map(col => item[col] ? `"${item[col].toString().replace(/"/g, '""')}"` : "").join(',');
858
- writeStream.write(row + '\n');
866
+ writeStream.write(csvRow(item, columns) + '\n');
859
867
  });
860
868
 
861
869
  writeStream.end();
@@ -1370,7 +1378,7 @@ META
1370
1378
  * @param {Config} config
1371
1379
  */
1372
1380
  function buildFileNames(config) {
1373
- const { format = "csv", groupKeys = [], lookupTables = [] } = config;
1381
+ const { format = "csv", groupKeys = [], lookupTables = [], warehouseMetrics = [] } = config;
1374
1382
  let extension = "";
1375
1383
  extension = format === "csv" ? "csv" : "json";
1376
1384
  // const current = dayjs.utc().format("MM-DD-HH");
@@ -1388,10 +1396,12 @@ function buildFileNames(config) {
1388
1396
  eventFiles: [path.join(writeDir, `${simName}-EVENTS.${extension}`)],
1389
1397
  userFiles: [path.join(writeDir, `${simName}-USERS.${extension}`)],
1390
1398
  adSpendFiles: [],
1399
+ standaloneFiles: [],
1391
1400
  scdFiles: [],
1392
1401
  mirrorFiles: [],
1393
1402
  groupFiles: [],
1394
1403
  lookupFiles: [],
1404
+ warehouseFiles: [],
1395
1405
  folder: writeDir,
1396
1406
  };
1397
1407
  //add ad spend files
@@ -1399,6 +1409,11 @@ function buildFileNames(config) {
1399
1409
  writePaths.adSpendFiles.push(path.join(writeDir, `${simName}-AD-SPEND.${extension}`));
1400
1410
  }
1401
1411
 
1412
+ //add standalone (identity-less snapshot) files — v1.8.0
1413
+ if (Array.isArray(config?.standaloneEvents) && config.standaloneEvents.length > 0) {
1414
+ writePaths.standaloneFiles.push(path.join(writeDir, `${simName}-STANDALONE.${extension}`));
1415
+ }
1416
+
1402
1417
  //add SCD files
1403
1418
  const scdKeys = Object.keys(config?.scdProps || {});
1404
1419
  for (const key of scdKeys) {
@@ -1425,6 +1440,15 @@ function buildFileNames(config) {
1425
1440
  );
1426
1441
  }
1427
1442
 
1443
+ for (const warehouseMetric of warehouseMetrics) {
1444
+ const metricName = warehouseMetric?.name;
1445
+ if (typeof metricName !== 'string') continue;
1446
+ const metricFormat = warehouseMetric?.format || format || 'csv';
1447
+ writePaths.warehouseFiles.push(
1448
+ path.join(writeDir, `${simName}-WAREHOUSE-${metricName}.${metricFormat}`)
1449
+ );
1450
+ }
1451
+
1428
1452
  //add mirror files
1429
1453
  const mirrorProps = config?.mirrorProps || {};
1430
1454
  if (Object.keys(mirrorProps).length) {
@@ -1948,6 +1972,7 @@ export {
1948
1972
  generateUser,
1949
1973
  optimizedBoxMuller,
1950
1974
  buildFileNames,
1975
+ csvRow,
1951
1976
  streamJSON,
1952
1977
  streamCSV,
1953
1978
  streamParquet,
@@ -17,6 +17,7 @@ export { verifyDungeon } from './verify-dungeon.js';
17
17
  // `evaluateStories` / `applyFunnelDefaults`. Use the RETURN value; it does not
18
18
  // enrich the config you pass in.
19
19
  export { validateDungeonConfig } from '../core/config-validator.js';
20
+ export { pearson, computeWarehouseStats, computeWarehouseSourceRows, auditWarehouseRows } from './warehouse.js';
20
21
  export { deriveExpectedSchema, validateSchema } from './schema-validator.js';
21
22
  export {
22
23
  evaluateFunnel,
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { emulateBreakdown } from './emulate-breakdown.js';
29
29
  import { applyFunnelDefaults } from './verify-dungeon.js';
30
+ import { computeWarehouseSourceRows, computeWarehouseStats } from './warehouse.js';
30
31
 
31
32
  /** Closed archetype enum — MUST match lib/templates/story-spec.schema.json (unit-tested). */
32
33
  export const STORY_ARCHETYPES = [
@@ -322,6 +323,9 @@ export function validateStories(stories) {
322
323
  err(ap, 'breakdown: required object with a string `type`');
323
324
  } else if (a.breakdown.type === 'duckdb' && (typeof a.breakdown.sql !== 'string' || !a.breakdown.sql)) {
324
325
  err(ap, 'breakdown: type "duckdb" requires a non-empty `sql`');
326
+ } else if ((a.breakdown.type === 'warehouse' || a.breakdown.type === 'warehouse-stats')
327
+ && (typeof a.breakdown.table !== 'string' || !a.breakdown.table.trim())) {
328
+ err(ap, `breakdown: type "${a.breakdown.type}" requires a non-empty \`table\``);
325
329
  }
326
330
  if (a.expect === undefined && typeof a.assert !== 'function') {
327
331
  err(ap, 'requires `expect` or a function-valued `assert`');
@@ -339,8 +343,8 @@ export function validateStories(stories) {
339
343
  for (const [name, spec] of Object.entries(a.select)) {
340
344
  if (!NAME_RE.test(name)) err(ap, `select: name "${name}" must be an identifier`);
341
345
  selectNames.add(name);
342
- if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object' || !Object.keys(spec.where).length) {
343
- err(ap, `select.${name}: requires a non-empty \`where\``);
346
+ if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object') {
347
+ err(ap, `select.${name}: requires a \`where\` object`);
344
348
  continue;
345
349
  }
346
350
  for (const [col, cond] of Object.entries(spec.where)) {
@@ -406,8 +410,8 @@ export function storiesToChecks(stories) {
406
410
  for (const story of stories) {
407
411
  story.assertions.forEach((assertion, i) => {
408
412
  const name = `${story.id}[${i}]`;
409
- if (assertion.breakdown.type === 'duckdb') {
410
- console.warn(`[dungeon-master] ${name}: duckdb assertions only run in disk mode (scripts/verify-stories.mjs) — skipped in-memory.`);
413
+ if (assertion.breakdown.type === 'duckdb' || assertion.breakdown.type === 'warehouse' || assertion.breakdown.type === 'warehouse-stats') {
414
+ console.warn(`[dungeon-master] ${name}: ${assertion.breakdown.type} assertions only run in disk mode via scripts/verify-stories.mjs — skipped in verifyDungeon in-memory checks.`);
411
415
  return;
412
416
  }
413
417
  checks.push({
@@ -441,11 +445,34 @@ export function storiesToChecks(stories) {
441
445
  * auto-build over large profile sets is wasteful.
442
446
  * @param {(sql: string) => Promise<Array<Object>>} [opts.runSql] - duckdb
443
447
  * executor (provided by the CLI in disk mode). Absent → duckdb assertions
444
- * report NONE with an explanatory detail.
448
+ * report NONE unless skipDiskOnlyDuckdb is set.
449
+ * @param {Record<string, Array<Object>>} [opts.warehouseRows] - Loaded
450
+ * warehouse rows keyed by table name.
451
+ * @param {Record<string, Object>} [opts.warehouseSpecs] - Resolved warehouse
452
+ * metric specs keyed by table name.
453
+ * @param {string|number} [opts.datasetStart] - Dataset window start for
454
+ * in-window warehouse stats comparisons.
455
+ * @param {string|number} [opts.datasetEnd] - Dataset window end for in-window
456
+ * warehouse stats comparisons.
457
+ * @param {boolean} [opts.skipDiskOnlyDuckdb] - Mark duckdb assertions skipped
458
+ * instead of NONE when no SQL executor is available.
459
+ * @param {(message: string) => void} [opts.onWarning] - Warning sink for
460
+ * skipped disk-only assertions.
445
461
  * @returns {Promise<Array<{ id: string, hook: string, archetype: string, verdict: string, assertions: Array<{ name: string, verdict: string, observed: number|null, detail: string }> }>>}
446
462
  */
447
463
  export async function evaluateStories(stories, events, opts) {
448
- const { profiles, funnels, runSql, identityMap } = opts || {};
464
+ const {
465
+ profiles,
466
+ funnels,
467
+ runSql,
468
+ identityMap,
469
+ warehouseRows,
470
+ warehouseSpecs,
471
+ datasetStart,
472
+ datasetEnd,
473
+ skipDiskOnlyDuckdb,
474
+ onWarning,
475
+ } = opts || {};
449
476
  const v = validateStories(stories);
450
477
  if (!v.valid) {
451
478
  throw new Error(`evaluateStories: invalid stories:\n ${v.errors.join('\n ')}`);
@@ -460,10 +487,21 @@ export async function evaluateStories(stories, events, opts) {
460
487
  try {
461
488
  if (assertion.breakdown.type === 'duckdb') {
462
489
  if (!runSql) {
463
- results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
490
+ if (skipDiskOnlyDuckdb) {
491
+ onWarning?.(`[dungeon-master] ${name}: duckdb assertions only run in disk mode (scripts/verify-stories.mjs) — skipped in-memory.`);
492
+ results.push({ name, verdict: 'SKIPPED', observed: null, detail: 'duckdb assertion requires disk mode (skipped in-memory)' });
493
+ } else {
494
+ results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
495
+ }
464
496
  continue;
465
497
  }
466
498
  rows = await runSql(assertion.breakdown.sql);
499
+ } else if (assertion.breakdown.type === 'warehouse') {
500
+ rows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
501
+ } else if (assertion.breakdown.type === 'warehouse-stats') {
502
+ const spec = resolveWarehouseSpec(assertion.breakdown.table, warehouseSpecs);
503
+ const tableRows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
504
+ rows = [computeWarehouseStats(tableRows, spec, computeWarehouseSourceRows(events, spec), { datasetStart, datasetEnd })];
467
505
  } else {
468
506
  const bArgs = applyFunnelDefaults(assertion.breakdown, funnels, profiles);
469
507
  if (identityMap && bArgs.identityMap === undefined) bArgs.identityMap = identityMap;
@@ -476,8 +514,33 @@ export async function evaluateStories(stories, events, opts) {
476
514
  const res = evaluateAssertion(rows, assertion, { events, profiles });
477
515
  results.push({ name, ...res });
478
516
  }
479
- const worst = results.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED');
517
+ const counted = results.filter((result) => result.verdict !== 'SKIPPED');
518
+ const worst = counted.length
519
+ ? counted.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED')
520
+ : 'SKIPPED';
480
521
  out.push({ id: story.id, hook: story.hook, archetype: story.archetype, verdict: worst, assertions: results });
481
522
  }
482
523
  return out;
483
524
  }
525
+
526
+ function resolveWarehouseSpec(table, warehouseSpecs) {
527
+ const spec = warehouseSpecs?.[table];
528
+ if (!spec) throw new Error(`warehouse table "${table}" is not available`);
529
+ return spec;
530
+ }
531
+
532
+ function resolveWarehouseRows(table, warehouseRows, warehouseSpecs) {
533
+ const spec = resolveWarehouseSpec(table, warehouseSpecs);
534
+ const rows = warehouseRows?.[table];
535
+ if (!Array.isArray(rows)) throw new Error(`warehouse rows for table "${table}" are not available`);
536
+ return rows.map((row) => withWarehouseTimestamp(row, spec));
537
+ }
538
+
539
+ function withWarehouseTimestamp(row, spec) {
540
+ if (Number.isFinite(row?.__t)) return row;
541
+ const raw = row?.[spec.timeColumn];
542
+ const parsed = typeof raw === 'string'
543
+ ? Date.parse(/T/.test(raw) ? raw : `${raw}T00:00:00Z`)
544
+ : NaN;
545
+ return { ...row, __t: Number.isFinite(parsed) ? parsed / 1000 : null };
546
+ }