@ak--47/dungeon-master 1.6.5 → 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 (45) 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 +331 -0
  18. package/HOOKS.md +154 -5
  19. package/README.md +357 -8
  20. package/docs/guides/1.7.0-upgrade-guide.md +154 -0
  21. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  22. package/dungeons/technical/warehouse.js +187 -0
  23. package/index.js +131 -2
  24. package/lib/core/config-validator.js +264 -13
  25. package/lib/core/context.js +39 -0
  26. package/lib/core/dungeon-loader.js +5 -2
  27. package/lib/core/storage.js +51 -3
  28. package/lib/generators/events.js +53 -7
  29. package/lib/generators/funnels.js +85 -11
  30. package/lib/generators/profiles.js +9 -4
  31. package/lib/generators/standalone.js +248 -0
  32. package/lib/generators/warehouse.js +828 -0
  33. package/lib/orchestrators/mixpanel-sender.js +39 -3
  34. package/lib/orchestrators/user-loop.js +240 -9
  35. package/lib/templates/story-spec.schema.json +41 -16
  36. package/lib/utils/conditions.js +62 -0
  37. package/lib/utils/json-evaluator.js +12 -2
  38. package/lib/utils/utils.js +115 -19
  39. package/lib/verify/index.js +1 -0
  40. package/lib/verify/schema-validator.js +8 -0
  41. package/lib/verify/story-runner.js +71 -8
  42. package/lib/verify/warehouse.js +683 -0
  43. package/package.json +5 -11
  44. package/scripts/verify-stories.mjs +150 -44
  45. package/types.d.ts +606 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.6.5",
3
+ "version": "1.8.0",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -48,15 +48,7 @@
48
48
  "dungeon:run": "node ./scripts/run-dungeon.mjs",
49
49
  "dungeon:to-json": "node ./scripts/dungeon-to-json.mjs",
50
50
  "dungeon:from-json": "node ./scripts/json-to-dungeon.mjs",
51
- "dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs",
52
- "kodiak:deploy": "gcloud builds submit --config dungeons/user/kodiak/cloudbuild.yaml",
53
- "kodiak:plan": "node dungeons/user/kodiak/kickoff.mjs --plan-only",
54
- "kodiak:smoke:local": "bash dungeons/user/kodiak/smoke-local.sh",
55
- "kodiak:smoke": "node dungeons/user/kodiak/kickoff.mjs --total-events 1000000000 --chunk-cap 10",
56
- "kodiak:generate": "node dungeons/user/kodiak/kickoff.mjs --no-wait",
57
- "kodiak:status": "node dungeons/user/kodiak/status.mjs",
58
- "kodiak:run": "bash dungeons/user/kodiak/run-all.sh",
59
- "kodiak:load": "node dungeons/user/kodiak/load-bq.mjs --replace --no-wait"
51
+ "dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs"
60
52
  },
61
53
  "repository": {
62
54
  "type": "git",
@@ -83,6 +75,7 @@
83
75
  "@google-cloud/storage": "^7.14.0",
84
76
  "ak-tools": "^1.1.12",
85
77
  "chance": "^1.1.11",
78
+ "csv-parse": "^7.0.2",
86
79
  "dayjs": "^1.11.11",
87
80
  "dotenv": "^16.4.5",
88
81
  "hyparquet-writer": "^0.6.1",
@@ -100,7 +93,8 @@
100
93
  "@vitest/ui": "^2.1.9",
101
94
  "nodemon": "^3.1.3",
102
95
  "typescript": "^5.6.0",
103
- "vitest": "^2.1.9"
96
+ "vitest": "^2.1.9",
97
+ "yaml": "^2.9.0"
104
98
  },
105
99
  "nodemonConfig": {
106
100
  "ignore": [
@@ -34,15 +34,18 @@ import readline from 'readline';
34
34
  import { execFile } from 'child_process';
35
35
  import { promisify } from 'util';
36
36
  import { pathToFileURL } from 'url';
37
+ import { parse as parseCsv } from 'csv-parse';
38
+ import generate from '../index.js';
37
39
  import { extractComments } from '../lib/core/extract-comments.js';
38
40
  import { validateDungeonConfig } from '../lib/core/config-validator.js';
39
41
  import {
40
42
  buildIdentityMap,
41
- verifyDungeon,
42
43
  VERDICT_RANK,
43
44
  validateStories,
44
- storiesToChecks,
45
+ validateSchema,
45
46
  evaluateStories,
47
+ auditWarehouseRows,
48
+ computeWarehouseSourceRows,
46
49
  } from '../lib/verify/index.js';
47
50
 
48
51
  const USAGE = `Usage: node scripts/verify-stories.mjs <dungeon-path> [--data-prefix <prefix>] [--in-memory] [--json]
@@ -89,13 +92,16 @@ if (!fs.existsSync(abs)) die(`verify-stories: dungeon not found at ${abs}`);
89
92
 
90
93
  const mod = await import(pathToFileURL(abs).href);
91
94
  const config = mod.default;
92
- const stories = mod.stories;
95
+ const stories = Array.isArray(mod.stories) ? mod.stories : [];
93
96
  if (!config || typeof config !== 'object') die(`verify-stories: ${dungeonPath} has no default-exported config object`);
94
- if (!Array.isArray(stories) || !stories.length) {
97
+ const hasWarehouseMetrics = Array.isArray(config.warehouseMetrics) && config.warehouseMetrics.length > 0;
98
+ if (!stories.length && !hasWarehouseMetrics) {
95
99
  die(`verify-stories: ${dungeonPath} has no \`stories\` named export — add one (see lib/templates/story-spec.schema.json) or use scripts/verify-runner.mjs for ad-hoc checks.`);
96
100
  }
97
- const sv = validateStories(stories);
98
- if (!sv.valid) die(`verify-stories: invalid stories:\n ${sv.errors.join('\n ')}`);
101
+ if (stories.length) {
102
+ const sv = validateStories(stories);
103
+ if (!sv.valid) die(`verify-stories: invalid stories:\n ${sv.errors.join('\n ')}`);
104
+ }
99
105
 
100
106
  // ── coverage discipline ─────────────────────────────────────────────────────
101
107
  // Every numbered hook in the HOOK STORIES comment block needs >=1 story. One
@@ -129,28 +135,30 @@ const coverage = {
129
135
 
130
136
  let storyResults; // Array<{ id, hook, archetype, verdict, assertions }>
131
137
  let schemaPass = true;
138
+ let warehouseAudits = [];
132
139
 
133
140
  if (inMemory) {
134
- const checks = storiesToChecks(stories); // warns + skips duckdb assertions
135
- if (!checks.length) die('verify-stories: every assertion is duckdb (disk-mode-only) nothing to run in-memory. Drop --in-memory.');
136
- // token: '' prevents any Mixpanel send; in-memory verification never writes.
137
- const report = await verifyDungeon({ ...config, token: '', writeToDisk: false }, checks);
138
- schemaPass = !!report.schemaReport?.pass;
139
- const byStory = new Map(stories.map(s => [s.id, { id: s.id, hook: s.hook, archetype: s.archetype, verdict: null, assertions: [] }]));
140
- for (const r of report.results) {
141
- const m = /^(.+)\[(\d+)\]$/.exec(r.name);
142
- const st = m && byStory.get(m[1]);
143
- if (!st) continue;
144
- const verdict = (/^([A-Z]+) /.exec(r.detail || '') || [])[1] || (r.pass ? 'STRONG' : 'NONE');
145
- st.assertions.push({ name: r.name, verdict, observed: null, detail: r.detail || '' });
146
- }
147
- for (const st of byStory.values()) {
148
- // duckdb-only stories have zero in-memory assertions: informational SKIPPED.
149
- st.verdict = st.assertions.length
150
- ? st.assertions.reduce((w, a) => VERDICT_RANK[a.verdict] < VERDICT_RANK[w] ? a.verdict : w, 'NAILED')
151
- : 'SKIPPED';
152
- }
153
- storyResults = [...byStory.values()];
141
+ const result = await generate({ ...config, token: '', writeToDisk: false });
142
+ const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData || []);
143
+ const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData || []);
144
+ const validated = result.validatedConfig || validateDungeonConfig({ ...config, token: '' });
145
+ schemaPass = !!validateSchema(events, validated)?.pass;
146
+ const warehouseSpecs = Object.fromEntries((validated.warehouseMetrics || []).map((spec) => [spec.name, spec]));
147
+ const warehouseRows = result.warehouseMetricData || {};
148
+ storyResults = stories.length
149
+ ? await evaluateStories(stories, events, {
150
+ profiles,
151
+ funnels: Array.isArray(validated.funnels) ? validated.funnels : [],
152
+ identityMap: buildIdentityMap(profiles),
153
+ warehouseRows,
154
+ warehouseSpecs,
155
+ datasetStart: validated.datasetStart,
156
+ datasetEnd: validated.datasetEnd,
157
+ skipDiskOnlyDuckdb: true,
158
+ onWarning: (message) => console.error(message),
159
+ })
160
+ : [];
161
+ warehouseAudits = buildWarehouseAudits(warehouseSpecs, warehouseRows, validated, events);
154
162
  } else {
155
163
  const prefix = dataPrefix || `verify-${path.basename(abs).replace(/\.(js|mjs)$/, '')}`;
156
164
  const prefixPath = prefix.includes('/') ? prefix : path.join('data', prefix);
@@ -180,6 +188,12 @@ if (inMemory) {
180
188
  // enrich the object you hand it.
181
189
  const validated = validateDungeonConfig({ ...config, token: '' });
182
190
  const identityMap = buildIdentityMap(profiles);
191
+ schemaPass = !!validateSchema(events, validated)?.pass;
192
+ const warehouseSpecs = Object.fromEntries((validated.warehouseMetrics || []).map((spec) => [spec.name, spec]));
193
+ const warehouseManifest = loadWarehouseManifest(prefixPath);
194
+ const warehouseRows = Object.fromEntries(await Promise.all(
195
+ Object.values(warehouseSpecs).map(async (spec) => [spec.name, await loadWarehouseRows(prefixPath, spec, warehouseManifest)])
196
+ ));
183
197
 
184
198
  const execFileP = promisify(execFile);
185
199
  const runSql = async (sql) => {
@@ -195,19 +209,27 @@ if (inMemory) {
195
209
  return trimmed ? JSON.parse(trimmed) : [];
196
210
  };
197
211
 
198
- storyResults = await evaluateStories(stories, events, {
199
- profiles,
200
- funnels: Array.isArray(validated.funnels) ? validated.funnels : [],
201
- identityMap,
202
- runSql,
203
- });
212
+ storyResults = stories.length
213
+ ? await evaluateStories(stories, events, {
214
+ profiles,
215
+ funnels: Array.isArray(validated.funnels) ? validated.funnels : [],
216
+ identityMap,
217
+ runSql,
218
+ warehouseRows,
219
+ warehouseSpecs,
220
+ datasetStart: validated.datasetStart,
221
+ datasetEnd: validated.datasetEnd,
222
+ })
223
+ : [];
224
+ warehouseAudits = buildWarehouseAudits(warehouseSpecs, warehouseRows, validated, events, warehouseManifest);
204
225
  }
205
226
 
206
227
  // ── report ──────────────────────────────────────────────────────────────────
207
228
 
208
229
  const counted = storyResults.filter(s => s.verdict !== 'SKIPPED');
209
230
  const failing = counted.filter(s => VERDICT_RANK[s.verdict] < VERDICT_RANK.STRONG);
210
- const pass = failing.length === 0 && missing.length === 0 && schemaPass;
231
+ const failingAudits = warehouseAudits.filter((audit) => !audit.pass);
232
+ const pass = failing.length === 0 && failingAudits.length === 0 && missing.length === 0 && schemaPass;
211
233
 
212
234
  if (asJson) {
213
235
  console.log(JSON.stringify({
@@ -215,26 +237,36 @@ if (asJson) {
215
237
  mode: inMemory ? 'in-memory' : 'disk',
216
238
  coverage,
217
239
  stories: storyResults,
240
+ warehouseAudits,
218
241
  schemaPass,
219
242
  pass,
220
243
  }, null, 2));
221
244
  } else {
222
- const wId = Math.max(5, ...storyResults.map(s => s.id.length));
223
- const wHook = Math.max(4, ...storyResults.map(s => String(s.hook).length));
224
- const wArch = Math.max(9, ...storyResults.map(s => s.archetype.length));
225
- console.log('');
226
- console.log(`${'STORY'.padEnd(wId)} ${'HOOK'.padEnd(wHook)} ${'ARCHETYPE'.padEnd(wArch)} VERDICT`);
227
- for (const s of storyResults) {
228
- console.log(`${s.id.padEnd(wId)} ${String(s.hook).padEnd(wHook)} ${s.archetype.padEnd(wArch)} ${s.verdict}`);
229
- for (const a of s.assertions) {
230
- console.log(` ${a.name.padEnd(wId)} ${a.verdict} — ${a.detail.replace(/^[A-Z]+ — /, '')}`);
245
+ if (storyResults.length) {
246
+ const wId = Math.max(5, ...storyResults.map(s => s.id.length));
247
+ const wHook = Math.max(4, ...storyResults.map(s => String(s.hook).length));
248
+ const wArch = Math.max(9, ...storyResults.map(s => s.archetype.length));
249
+ console.log('');
250
+ console.log(`${'STORY'.padEnd(wId)} ${'HOOK'.padEnd(wHook)} ${'ARCHETYPE'.padEnd(wArch)} VERDICT`);
251
+ for (const s of storyResults) {
252
+ console.log(`${s.id.padEnd(wId)} ${String(s.hook).padEnd(wHook)} ${s.archetype.padEnd(wArch)} ${s.verdict}`);
253
+ for (const a of s.assertions) {
254
+ console.log(` ${a.name.padEnd(wId)} ${a.verdict} — ${a.detail.replace(/^[A-Z]+ — /, '')}`);
255
+ }
231
256
  }
257
+ console.log('');
232
258
  }
233
- console.log('');
234
259
  const tally = {};
235
260
  for (const s of counted) tally[s.verdict] = (tally[s.verdict] || 0) + 1;
236
261
  const skipped = storyResults.length - counted.length;
237
- console.log(`${counted.length} stories: ${Object.entries(tally).map(([v, n]) => `${n} ${v}`).join(', ') || 'none'}${skipped ? ` (${skipped} skipped — duckdb-only, disk mode required)` : ''}`);
262
+ console.log(`${counted.length} stories: ${Object.entries(tally).map(([v, n]) => `${n} ${v}`).join(', ') || 'none'}${skipped ? ` (${skipped} skipped — disk mode required)` : ''}`);
263
+ if (warehouseAudits.length) {
264
+ console.log('warehouse audit:');
265
+ for (const audit of warehouseAudits) {
266
+ const summary = `stats corr=${formatNumber(audit.stats.corr)} buckets=${audit.stats.buckets} gaps=${audit.stats.gaps} empty=${audit.stats.emptyNumericCells}`;
267
+ console.log(` ${audit.table}: ${audit.pass ? 'PASS' : 'FAIL'} — ${audit.failures.length ? audit.failures.join('; ') : summary}`);
268
+ }
269
+ }
238
270
  if (coverage.note) console.log(`coverage: ${coverage.note}`);
239
271
  else if (missing.length) console.log(`coverage: FAIL — hooks with no story: ${missing.map(n => `H${n}`).join(', ')} (declared H${coverage.declared.join(', H')})`);
240
272
  else console.log(`coverage: ${coverage.declared.length} hooks declared in HOOK STORIES, all covered`);
@@ -243,3 +275,77 @@ if (asJson) {
243
275
  }
244
276
 
245
277
  process.exit(pass ? 0 : 1);
278
+
279
+ function buildWarehouseAudits(warehouseSpecs, warehouseRows, validated, events = [], warehouseManifest = null) {
280
+ return Object.values(warehouseSpecs || {}).map((spec) => {
281
+ const rows = warehouseRows?.[spec.name] || [];
282
+ const sourceRows = events.length ? computeWarehouseSourceRows(events, spec) : [];
283
+ const manifestColumns = warehouseManifest?.tables?.find((table) => table.table === spec.name)?.columns || [];
284
+ return {
285
+ table: spec.name,
286
+ ...auditWarehouseRows(rows, spec, {
287
+ datasetStart: validated.datasetStart,
288
+ datasetEnd: validated.datasetEnd,
289
+ sourceRows,
290
+ manifestColumns,
291
+ }),
292
+ };
293
+ });
294
+ }
295
+
296
+ function loadWarehouseManifest(prefixPath) {
297
+ const manifestPath = `${prefixPath}-WAREHOUSE-MANIFEST.json`;
298
+ if (!fs.existsSync(manifestPath)) return null;
299
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
300
+ }
301
+
302
+ async function loadWarehouseRows(prefixPath, spec, manifest) {
303
+ const manifestEntry = manifest?.tables?.find((table) => table.table === spec.name) || null;
304
+ const fileBase = manifestEntry?.file || `${path.basename(prefixPath)}-WAREHOUSE-${spec.name}`;
305
+ const format = manifestEntry?.format || spec.format || 'csv';
306
+ const filePath = path.join(path.dirname(prefixPath), `${fileBase}.${format}`);
307
+ if (!fs.existsSync(filePath)) return [];
308
+ if (format === 'json') return loadNdjson(filePath);
309
+ return loadCsv(filePath, manifestEntry?.columns || []);
310
+ }
311
+
312
+ async function loadNdjson(filePath) {
313
+ const out = [];
314
+ const rl = readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity });
315
+ for await (const line of rl) {
316
+ if (line.trim()) out.push(JSON.parse(line));
317
+ }
318
+ return out;
319
+ }
320
+
321
+ async function loadCsv(filePath, columns) {
322
+ const out = [];
323
+ const parser = fs.createReadStream(filePath).pipe(parseCsv({
324
+ bom: true,
325
+ columns: true,
326
+ skip_empty_lines: true,
327
+ }));
328
+ for await (const record of parser) {
329
+ const row = {};
330
+ for (const [header, rawValue] of Object.entries(record)) {
331
+ const type = columns.find((column) => column.name === header)?.bqType;
332
+ row[header] = coerceWarehouseCell(rawValue ?? '', type);
333
+ }
334
+ out.push(row);
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function coerceWarehouseCell(value, bqType) {
340
+ if (bqType === 'FLOAT64') {
341
+ if (value === '') return '';
342
+ const numeric = Number(value);
343
+ return Number.isFinite(numeric) ? numeric : value;
344
+ }
345
+ if (bqType === 'BOOL') return value === 'true';
346
+ return value;
347
+ }
348
+
349
+ function formatNumber(value) {
350
+ return Number.isFinite(value) ? value.toFixed(3) : String(value);
351
+ }