@ak--47/dungeon-master 1.5.4 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Story runner (v1.6, P3.3) — mechanical evaluation of a dungeon's `stories`
3
+ * export against emulator output. The five-tier verdict scale (SPEC P3.2,
4
+ * mirrored in `.claude/skills/verify-dungeon/references/report-format.md`):
5
+ *
6
+ * NAILED — observed within ±10% of `target`
7
+ * STRONG — passes `floor` (or `target` when no floor is given)
8
+ * WEAK — fails floor but effect direction is correct, or any selection's
9
+ * cohort < `minCohort` (population cap: NAILED/STRONG demote to WEAK)
10
+ * NONE — no measurable effect: selection empty, metric not computable,
11
+ * observed sits exactly at the neutral point, or a single-ref
12
+ * metric (no neutral point → no direction test) fails its floor
13
+ * INVERSE — effect direction opposite the assertion
14
+ *
15
+ * Story verdict = worst assertion (INVERSE < NONE < WEAK < STRONG < NAILED).
16
+ *
17
+ * Direction ("effect") is mechanical: ratio metrics have neutral point 1,
18
+ * difference metrics 0, single-ref metrics none. For op '>='/'>' the effect
19
+ * direction is correct when observed > neutral; for '<='/'<' when observed <
20
+ * neutral. For 'between' the wanted side is where the band's midpoint sits
21
+ * relative to the neutral point.
22
+ *
23
+ * Cohort size per named selection: sum of `user_count` over the selected rows
24
+ * when any row carries one, else the row count. The SMALLEST selection is
25
+ * compared against `minCohort`.
26
+ */
27
+
28
+ import { emulateBreakdown } from './emulate-breakdown.js';
29
+ import { applyFunnelDefaults } from './verify-dungeon.js';
30
+
31
+ /** Closed archetype enum — MUST match lib/templates/story-spec.schema.json (unit-tested). */
32
+ export const STORY_ARCHETYPES = [
33
+ 'cohort-count-scale',
34
+ 'cohort-prop-scale',
35
+ 'temporal-inflection',
36
+ 'funnel-conversion-by-segment',
37
+ 'funnel-ttc-by-segment',
38
+ 'retention-divergence',
39
+ 'frequency-sweet-spot',
40
+ 'attribution-bias',
41
+ 'experiment-lift',
42
+ 'lifecycle-wave',
43
+ 'path-share',
44
+ 'session-shape',
45
+ 'composition-drift',
46
+ 'bespoke',
47
+ ];
48
+
49
+ /** Verdict rank, worst → best. Story verdict = min rank across assertions. */
50
+ export const VERDICT_RANK = { INVERSE: 0, NONE: 1, WEAK: 2, STRONG: 3, NAILED: 4 };
51
+
52
+ const EXPECT_OPS = ['>=', '<=', '>', '<', 'between'];
53
+ const WHERE_OPS = ['==', '!=', '>=', '<=', '>', '<'];
54
+ const NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
55
+ const HOOK_RE = /^(?:H|Hook\s*)?(\d+)$/i;
56
+ const NUM_RE = /^[0-9]+(\.[0-9]+)?$/;
57
+
58
+ // Columns that SUM across a multi-row selection. Everything else is
59
+ // value-like: multi-row selection is an authoring error (which row's avg?).
60
+ const COUNT_LIKE = new Set(['count', 'user_count', 'event_count', 'conversions', 'total', 'uniques', 'entries', 'sessions']);
61
+ const isCountLike = (col) => COUNT_LIKE.has(col) || /_count$/.test(col);
62
+
63
+ // ── metric grammar ──────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Parse the pinned metric grammar: single ref `'<name>.<col>'`, ratio
67
+ * `'<a>.<col> / <b>.<col>'`, or difference `'<a>.<col> - <b>.<col>'`.
68
+ * Operands are refs or unsigned numeric literals; at most one operator; at
69
+ * least one ref. Identifiers can't contain `/` or `-`, so the first
70
+ * occurrence of either IS the operator.
71
+ * @param {string} metric
72
+ * @returns {{ kind: 'single'|'ratio'|'difference', left: Object, right: Object|null }}
73
+ */
74
+ export function parseMetric(metric) {
75
+ const s = String(metric || '').trim();
76
+ if (!s) throw new Error('metric is empty');
77
+ let opChar = null, idx = s.indexOf('/');
78
+ if (idx >= 0) opChar = '/';
79
+ else { idx = s.indexOf('-'); if (idx >= 0) opChar = '-'; }
80
+
81
+ const parseOperand = (tok) => {
82
+ const t = tok.trim();
83
+ if (NUM_RE.test(t)) return { literal: Number(t) };
84
+ const dot = t.split('.');
85
+ if (dot.length === 2 && NAME_RE.test(dot[0]) && NAME_RE.test(dot[1])) {
86
+ return { name: dot[0], column: dot[1] };
87
+ }
88
+ throw new Error(`metric operand "${t}" is neither a <name>.<column> ref nor a numeric literal`);
89
+ };
90
+
91
+ if (opChar === null) {
92
+ const left = parseOperand(s);
93
+ if (left.literal !== undefined) throw new Error(`metric "${s}" has no ref — a bare literal asserts nothing`);
94
+ return { kind: 'single', left, right: null };
95
+ }
96
+ const leftTok = s.slice(0, idx);
97
+ const rightTok = s.slice(idx + 1);
98
+ if (rightTok.includes('/') || rightTok.includes('-')) {
99
+ throw new Error(`metric "${s}" has more than one operator — at most one of / or - is allowed`);
100
+ }
101
+ const left = parseOperand(leftTok);
102
+ const right = parseOperand(rightTok);
103
+ if (left.literal !== undefined && right.literal !== undefined) {
104
+ throw new Error(`metric "${s}" has no ref — a pure-literal expression asserts nothing`);
105
+ }
106
+ return { kind: opChar === '/' ? 'ratio' : 'difference', left, right };
107
+ }
108
+
109
+ // ── select ──────────────────────────────────────────────────────────────────
110
+
111
+ function compareValues(rowVal, op, val) {
112
+ switch (op) {
113
+ case '==': return rowVal === val;
114
+ case '!=': return rowVal !== val;
115
+ case '>=': return rowVal >= val;
116
+ case '<=': return rowVal <= val;
117
+ case '>': return rowVal > val;
118
+ case '<': return rowVal < val;
119
+ default: throw new Error(`unknown where op "${op}"`);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Apply a story `select` to breakdown rows → named row-sets. All where-clauses
125
+ * AND together; a plain value means strict equality.
126
+ * @param {Array<Object>} rows
127
+ * @param {Object} select
128
+ * @returns {Record<string, Array<Object>>}
129
+ */
130
+ export function selectRows(rows, select) {
131
+ /** @type {Record<string, Array<Object>>} */
132
+ const out = {};
133
+ for (const [name, spec] of Object.entries(select || {})) {
134
+ const where = (spec && spec.where) || {};
135
+ out[name] = (rows || []).filter(row => {
136
+ for (const [col, cond] of Object.entries(where)) {
137
+ if (cond !== null && typeof cond === 'object') {
138
+ if (!compareValues(row[col], cond.op, cond.value)) return false;
139
+ } else if (row[col] !== cond) {
140
+ return false;
141
+ }
142
+ }
143
+ return true;
144
+ });
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function resolveRef(ref, selected) {
150
+ if (ref.literal !== undefined) return ref.literal;
151
+ const rows = selected[ref.name];
152
+ if (!rows) throw new Error(`metric references "${ref.name}" but select has no such row-set`);
153
+ if (!rows.length) throw new Error(`selection "${ref.name}" is empty`);
154
+ if (rows.length > 1 && !isCountLike(ref.column)) {
155
+ throw new Error(`selection "${ref.name}" matched ${rows.length} rows but "${ref.column}" is value-like — single-row selection required`);
156
+ }
157
+ let sum = 0;
158
+ for (const row of rows) {
159
+ const v = row[ref.column];
160
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
161
+ throw new Error(`"${ref.name}.${ref.column}" is not a finite number (got ${JSON.stringify(v)})`);
162
+ }
163
+ sum += v;
164
+ }
165
+ return sum;
166
+ }
167
+
168
+ function cohortOf(rows) {
169
+ if (rows.some(r => typeof r.user_count === 'number')) {
170
+ return rows.reduce((s, r) => s + (typeof r.user_count === 'number' ? r.user_count : 0), 0);
171
+ }
172
+ return rows.length;
173
+ }
174
+
175
+ // ── verdicts ────────────────────────────────────────────────────────────────
176
+
177
+ function compareOp(obs, op, bound) {
178
+ switch (op) {
179
+ case '>=': return obs >= bound;
180
+ case '<=': return obs <= bound;
181
+ case '>': return obs > bound;
182
+ case '<': return obs < bound;
183
+ default: throw new Error(`unknown op "${op}"`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Verdict for one observed value against an expect spec — the mechanical
189
+ * five-tier scale (see module doc). `neutral` is 1 for ratios, 0 for
190
+ * differences, null for single refs.
191
+ * @param {number} observed
192
+ * @param {import('../../types').StoryExpect} expectSpec
193
+ * @param {number|null} neutral
194
+ * @returns {{ verdict: string, detail: string }}
195
+ */
196
+ export function verdictFor(observed, expectSpec, neutral) {
197
+ const { op, target, floor } = expectSpec;
198
+ if (op === 'between') {
199
+ const [lo, hi] = /** @type {[number, number]} */ (target);
200
+ if (observed >= lo && observed <= hi) {
201
+ return { verdict: 'NAILED', detail: `observed ${fmt(observed)} within [${lo}, ${hi}]` };
202
+ }
203
+ if (neutral === null || observed === neutral) {
204
+ return { verdict: 'NONE', detail: `observed ${fmt(observed)} outside [${lo}, ${hi}], no direction signal` };
205
+ }
206
+ const mid = (lo + hi) / 2;
207
+ const wantAbove = mid > neutral;
208
+ const isAbove = observed > neutral;
209
+ return wantAbove === isAbove
210
+ ? { verdict: 'WEAK', detail: `observed ${fmt(observed)} outside [${lo}, ${hi}] but on the effect side of ${neutral}` }
211
+ : { verdict: 'INVERSE', detail: `observed ${fmt(observed)} on the wrong side of ${neutral} for band [${lo}, ${hi}]` };
212
+ }
213
+
214
+ const targetNum = /** @type {number} */ (target);
215
+ if (Math.abs(observed - targetNum) <= 0.1 * Math.abs(targetNum)) {
216
+ return { verdict: 'NAILED', detail: `observed ${fmt(observed)} within ±10% of target ${targetNum}` };
217
+ }
218
+ const bound = typeof floor === 'number' ? floor : targetNum;
219
+ if (compareOp(observed, op, bound)) {
220
+ return { verdict: 'STRONG', detail: `observed ${fmt(observed)} passes ${op} ${bound}` };
221
+ }
222
+ if (neutral === null || observed === neutral) {
223
+ return { verdict: 'NONE', detail: `observed ${fmt(observed)} fails ${op} ${bound}, no direction signal` };
224
+ }
225
+ const wantAbove = op === '>=' || op === '>';
226
+ const isAbove = observed > neutral;
227
+ return wantAbove === isAbove
228
+ ? { verdict: 'WEAK', detail: `observed ${fmt(observed)} fails ${op} ${bound} but direction vs ${neutral} is correct` }
229
+ : { verdict: 'INVERSE', detail: `observed ${fmt(observed)} is on the wrong side of ${neutral}` };
230
+ }
231
+
232
+ const fmt = (n) => (typeof n === 'number' && Number.isFinite(n)) ? (Math.abs(n) >= 100 ? n.toFixed(0) : n.toPrecision(4)) : String(n);
233
+
234
+ /**
235
+ * Evaluate one assertion against breakdown rows. Never throws — authoring or
236
+ * data errors surface as verdict NONE with the error in `detail` (and NONE
237
+ * fails the run, so nothing is silently swallowed).
238
+ * @param {Array<Object>} rows
239
+ * @param {import('../../types').StoryAssertion} assertion
240
+ * @param {Object} [ctx]
241
+ * @returns {{ verdict: string, observed: number|null, detail: string }}
242
+ */
243
+ export function evaluateAssertion(rows, assertion, ctx) {
244
+ try {
245
+ if (typeof assertion.assert === 'function') {
246
+ /** @type {{ pass?: boolean, detail?: string, verdict?: import('../../types').StoryVerdict }} */
247
+ const res = assertion.assert(rows, ctx) || {};
248
+ if (res.verdict && VERDICT_RANK[res.verdict] !== undefined) {
249
+ return { verdict: res.verdict, observed: null, detail: res.detail || 'custom assert' };
250
+ }
251
+ return res.pass
252
+ ? { verdict: 'STRONG', observed: null, detail: res.detail || 'custom assert passed' }
253
+ : { verdict: 'NONE', observed: null, detail: res.detail || 'custom assert failed' };
254
+ }
255
+ const parsed = parseMetric(assertion.expect.metric);
256
+ const selected = selectRows(rows, assertion.select);
257
+ const left = resolveRef(parsed.left, selected);
258
+ let observed, neutral;
259
+ if (parsed.kind === 'single') {
260
+ observed = left; neutral = null;
261
+ } else {
262
+ const right = resolveRef(parsed.right, selected);
263
+ if (parsed.kind === 'ratio') {
264
+ if (right === 0) throw new Error('ratio denominator is 0');
265
+ observed = left / right; neutral = 1;
266
+ } else {
267
+ observed = left - right; neutral = 0;
268
+ }
269
+ }
270
+ let { verdict, detail } = verdictFor(observed, assertion.expect, neutral);
271
+ if (typeof assertion.minCohort === 'number' && VERDICT_RANK[verdict] > VERDICT_RANK.WEAK) {
272
+ const smallest = Math.min(...Object.values(selected).map(cohortOf));
273
+ if (smallest < assertion.minCohort) {
274
+ verdict = 'WEAK';
275
+ detail += ` — capped: smallest cohort ${smallest} < minCohort ${assertion.minCohort}`;
276
+ }
277
+ }
278
+ return { verdict, observed, detail };
279
+ } catch (err) {
280
+ return { verdict: 'NONE', observed: null, detail: `error: ${err.message}` };
281
+ }
282
+ }
283
+
284
+ // ── validation (mirrors lib/templates/story-spec.schema.json) ───────────────
285
+
286
+ /**
287
+ * Validate a `stories` export against the pinned grammar. Pure + dependency-free
288
+ * (no ajv): mirrors the JSON schema; a unit test keeps the two in sync.
289
+ * @param {Array<import('../../types').DungeonStory>} stories
290
+ * @returns {{ valid: boolean, errors: string[] }}
291
+ */
292
+ export function validateStories(stories) {
293
+ const errors = [];
294
+ const err = (path, msg) => errors.push(`${path}: ${msg}`);
295
+ if (!Array.isArray(stories) || !stories.length) {
296
+ return { valid: false, errors: ['stories: must be a non-empty array'] };
297
+ }
298
+ const seenIds = new Set();
299
+ stories.forEach((story, si) => {
300
+ const sp = `stories[${si}]`;
301
+ if (!story || typeof story !== 'object') return err(sp, 'must be an object');
302
+ if (!story.id || typeof story.id !== 'string') err(sp, 'id: required non-empty string');
303
+ else if (seenIds.has(story.id)) err(sp, `id: duplicate "${story.id}"`);
304
+ else seenIds.add(story.id);
305
+ if (typeof story.hook !== 'string' || !HOOK_RE.test(story.hook.trim())) {
306
+ err(sp, `hook: must match 'H<n>' / 'Hook <n>' / '<n>' (got ${JSON.stringify(story.hook)})`);
307
+ }
308
+ if (!STORY_ARCHETYPES.includes(story.archetype)) {
309
+ err(sp, `archetype: "${story.archetype}" not in the closed enum`);
310
+ }
311
+ if (!story.narrative || typeof story.narrative !== 'string') err(sp, 'narrative: required non-empty string');
312
+ if (story.intentionalDeviations !== undefined && !Array.isArray(story.intentionalDeviations)) {
313
+ err(sp, 'intentionalDeviations: must be an array of strings');
314
+ }
315
+ if (!Array.isArray(story.assertions) || !story.assertions.length) {
316
+ return err(sp, 'assertions: required non-empty array');
317
+ }
318
+ story.assertions.forEach((a, ai) => {
319
+ const ap = `${sp}.assertions[${ai}]`;
320
+ if (!a || typeof a !== 'object') return err(ap, 'must be an object');
321
+ if (!a.breakdown || typeof a.breakdown !== 'object' || typeof a.breakdown.type !== 'string' || !a.breakdown.type) {
322
+ err(ap, 'breakdown: required object with a string `type`');
323
+ } else if (a.breakdown.type === 'duckdb' && (typeof a.breakdown.sql !== 'string' || !a.breakdown.sql)) {
324
+ err(ap, 'breakdown: type "duckdb" requires a non-empty `sql`');
325
+ }
326
+ if (a.expect === undefined && typeof a.assert !== 'function') {
327
+ err(ap, 'requires `expect` or a function-valued `assert`');
328
+ }
329
+ if (a.assert !== undefined && typeof a.assert !== 'function') {
330
+ err(ap, 'assert: must be a function');
331
+ }
332
+ if (a.minCohort !== undefined && (typeof a.minCohort !== 'number' || a.minCohort <= 0)) {
333
+ err(ap, 'minCohort: must be a positive number');
334
+ }
335
+ const selectNames = new Set();
336
+ if (a.select !== undefined) {
337
+ if (!a.select || typeof a.select !== 'object') err(ap, 'select: must be an object');
338
+ else {
339
+ for (const [name, spec] of Object.entries(a.select)) {
340
+ if (!NAME_RE.test(name)) err(ap, `select: name "${name}" must be an identifier`);
341
+ 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\``);
344
+ continue;
345
+ }
346
+ for (const [col, cond] of Object.entries(spec.where)) {
347
+ if (cond !== null && typeof cond === 'object') {
348
+ if (!WHERE_OPS.includes(cond.op)) err(ap, `select.${name}.where.${col}: op "${cond.op}" not in ${WHERE_OPS.join(' ')}`);
349
+ if (cond.value === undefined) err(ap, `select.${name}.where.${col}: comparison requires \`value\``);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ if (a.expect !== undefined) {
356
+ const ep = `${ap}.expect`;
357
+ if (!a.expect || typeof a.expect !== 'object') return err(ep, 'must be an object');
358
+ let parsed = null;
359
+ try {
360
+ parsed = parseMetric(a.expect.metric);
361
+ } catch (e) {
362
+ err(ep, `metric: ${e.message}`);
363
+ }
364
+ if (parsed) {
365
+ for (const ref of [parsed.left, parsed.right]) {
366
+ if (ref && ref.name && !selectNames.has(ref.name)) {
367
+ err(ep, `metric references "${ref.name}" — not a select row-set name`);
368
+ }
369
+ }
370
+ }
371
+ if (!EXPECT_OPS.includes(a.expect.op)) err(ep, `op: "${a.expect.op}" not in ${EXPECT_OPS.join(' ')}`);
372
+ if (a.expect.op === 'between') {
373
+ if (!Array.isArray(a.expect.target) || a.expect.target.length !== 2
374
+ || !a.expect.target.every(n => typeof n === 'number' && Number.isFinite(n))
375
+ || a.expect.target[0] > a.expect.target[1]) {
376
+ err(ep, 'target: op "between" requires [lo, hi] with lo <= hi');
377
+ }
378
+ } else if (typeof a.expect.target !== 'number' || !Number.isFinite(a.expect.target)) {
379
+ err(ep, `target: must be a finite number (array form is between-only)`);
380
+ }
381
+ if (a.expect.floor !== undefined && (typeof a.expect.floor !== 'number' || !Number.isFinite(a.expect.floor))) {
382
+ err(ep, 'floor: must be a finite number');
383
+ }
384
+ }
385
+ });
386
+ });
387
+ return { valid: errors.length === 0, errors };
388
+ }
389
+
390
+ // ── adapters ────────────────────────────────────────────────────────────────
391
+
392
+ /**
393
+ * Convert stories into `verifyDungeon` checks for in-memory mode. Each
394
+ * assertion becomes one check named `<storyId>[<i>]`; pass = NAILED or STRONG
395
+ * (the verdict lands in `detail`). duckdb assertions are disk-mode-only —
396
+ * skipped here with a warning. Throws on invalid stories.
397
+ * @param {Array<import('../../types').DungeonStory>} stories
398
+ * @returns {Array<Object>}
399
+ */
400
+ export function storiesToChecks(stories) {
401
+ const v = validateStories(stories);
402
+ if (!v.valid) {
403
+ throw new Error(`storiesToChecks: invalid stories:\n ${v.errors.join('\n ')}`);
404
+ }
405
+ const checks = [];
406
+ for (const story of stories) {
407
+ story.assertions.forEach((assertion, i) => {
408
+ 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.`);
411
+ return;
412
+ }
413
+ checks.push({
414
+ name,
415
+ breakdown: assertion.breakdown,
416
+ assert: (rows, ctx) => {
417
+ const res = evaluateAssertion(rows, assertion, ctx);
418
+ return {
419
+ pass: VERDICT_RANK[res.verdict] >= VERDICT_RANK.STRONG,
420
+ detail: `${res.verdict} — ${res.detail}`,
421
+ };
422
+ },
423
+ });
424
+ });
425
+ }
426
+ return checks;
427
+ }
428
+
429
+ /**
430
+ * Evaluate stories against an already-loaded event set (disk mode). Funnel
431
+ * options auto-thread exactly as in `verifyDungeon` (via `applyFunnelDefaults`).
432
+ * @param {Array<import('../../types').DungeonStory>} stories
433
+ * @param {Array<Object>} events
434
+ * @param {Object} [opts]
435
+ * @param {Array<Object>} [opts.profiles]
436
+ * @param {Array<Object>} [opts.funnels] - VALIDATED dungeon funnels.
437
+ * @param {Map<string,string>} [opts.identityMap] - Pre-built device_id →
438
+ * canonical id map (buildIdentityMap). Threaded into every emulator
439
+ * assertion so pre-auth (device_id-only) events resolve to the same user
440
+ * as post-auth events. Build once at the call site — per-assertion
441
+ * auto-build over large profile sets is wasteful.
442
+ * @param {(sql: string) => Promise<Array<Object>>} [opts.runSql] - duckdb
443
+ * executor (provided by the CLI in disk mode). Absent → duckdb assertions
444
+ * report NONE with an explanatory detail.
445
+ * @returns {Promise<Array<{ id: string, hook: string, archetype: string, verdict: string, assertions: Array<{ name: string, verdict: string, observed: number|null, detail: string }> }>>}
446
+ */
447
+ export async function evaluateStories(stories, events, opts) {
448
+ const { profiles, funnels, runSql, identityMap } = opts || {};
449
+ const v = validateStories(stories);
450
+ if (!v.valid) {
451
+ throw new Error(`evaluateStories: invalid stories:\n ${v.errors.join('\n ')}`);
452
+ }
453
+ const out = [];
454
+ for (const story of stories) {
455
+ const results = [];
456
+ for (let i = 0; i < story.assertions.length; i++) {
457
+ const assertion = story.assertions[i];
458
+ const name = `${story.id}[${i}]`;
459
+ let rows;
460
+ try {
461
+ if (assertion.breakdown.type === 'duckdb') {
462
+ if (!runSql) {
463
+ results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
464
+ continue;
465
+ }
466
+ rows = await runSql(assertion.breakdown.sql);
467
+ } else {
468
+ const bArgs = applyFunnelDefaults(assertion.breakdown, funnels, profiles);
469
+ if (identityMap && bArgs.identityMap === undefined) bArgs.identityMap = identityMap;
470
+ rows = emulateBreakdown(events, bArgs);
471
+ }
472
+ } catch (err) {
473
+ results.push({ name, verdict: 'NONE', observed: null, detail: `error: ${err.message}` });
474
+ continue;
475
+ }
476
+ const res = evaluateAssertion(rows, assertion, { events, profiles });
477
+ results.push({ name, ...res });
478
+ }
479
+ const worst = results.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED');
480
+ out.push({ id: story.id, hook: story.hook, archetype: story.archetype, verdict: worst, assertions: results });
481
+ }
482
+ return out;
483
+ }
@@ -52,6 +52,64 @@ function findMatchingFunnel(funnels, steps) {
52
52
  return null;
53
53
  }
54
54
 
55
+ /**
56
+ * Auto-apply funnel-level dungeon config (conversion window, order mode,
57
+ * reentry, exclusion steps, step filters) plus the profiles arg to a breakdown
58
+ * args object, so check/story authors don't thread them by hand. Returns a NEW
59
+ * args object — the input is not mutated. Extracted from `verifyDungeon` in
60
+ * v1.6 so the story runner (P3.3) reuses the exact same threading.
61
+ * @param {Object} breakdownArgs - Args destined for `emulateBreakdown`.
62
+ * @param {Array<Object>} funnels - VALIDATED dungeon funnels (`validateDungeonConfig`
63
+ * mutates funnels in place, so post-run `config.funnels` carries resolved
64
+ * `conversionWindowDays` / `order`).
65
+ * @param {Array<Object>} [profiles] - User profiles, threaded into `timeToConvert`.
66
+ * @returns {Object}
67
+ */
68
+ export function applyFunnelDefaults(breakdownArgs, funnels, profiles) {
69
+ const args = { ...breakdownArgs };
70
+ if (args.type === 'timeToConvert' && !args.profiles && profiles) {
71
+ args.profiles = profiles;
72
+ }
73
+ // v1.5: auto-apply funnel-level config when the check targets a funnel.
74
+ if (args.type === 'funnelFrequency' || args.type === 'timeToConvert') {
75
+ const targetSteps = args.steps
76
+ || (args.fromEvent && args.toEvent
77
+ ? [args.fromEvent, args.toEvent]
78
+ : null);
79
+ const matched = findMatchingFunnel(Array.isArray(funnels) ? funnels : [], targetSteps);
80
+ if (matched) {
81
+ // An explicit session-count window (`conversionWindow: { unit: 'sessions', n }`)
82
+ // is the author choosing a window — injecting conversionWindowMs on top
83
+ // would trip evaluateFunnel's mutual-exclusion throw. Only inject when
84
+ // NEITHER window form is present.
85
+ if (args.conversionWindowMs === undefined && args.conversionWindow === undefined && Number.isFinite(matched.conversionWindowDays)) {
86
+ args.conversionWindowMs = matched.conversionWindowDays * 86400000;
87
+ }
88
+ if (args.funnelOrder === undefined && matched.order) {
89
+ args.funnelOrder = matched.order;
90
+ }
91
+ // v1.5.0: thread Funnel-level extension hints through to the verifier.
92
+ if (args.reentry === undefined && matched.reentry !== undefined) {
93
+ args.reentry = matched.reentry;
94
+ }
95
+ if (args.exclusionSteps === undefined && Array.isArray(matched.exclusionEvents) && matched.exclusionEvents.length) {
96
+ args.exclusionSteps = matched.exclusionEvents.map(name => ({ event: name }));
97
+ }
98
+ // stepFilters: Record<number, { prop, op, value }> → mutate steps to attach where-clause.
99
+ if (matched.stepFilters && args.steps && Array.isArray(args.steps)) {
100
+ args.steps = args.steps.map((s, i) => {
101
+ const filter = matched.stepFilters[i];
102
+ if (!filter) return s;
103
+ const stepObj = typeof s === 'string' ? { event: s } : { ...s };
104
+ if (!stepObj.where) stepObj.where = filter;
105
+ return stepObj;
106
+ });
107
+ }
108
+ }
109
+ }
110
+ return args;
111
+ }
112
+
55
113
  /**
56
114
  * @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
57
115
  * @param {VerifyCheck[]} checks
@@ -71,43 +129,7 @@ export async function verifyDungeon(config, checks) {
71
129
  const validatedFunnels = (config && Array.isArray(config.funnels)) ? config.funnels : [];
72
130
  for (const check of checks) {
73
131
  try {
74
- const breakdownArgs = { ...check.breakdown };
75
- if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
76
- breakdownArgs.profiles = profiles;
77
- }
78
- // v1.5: auto-apply funnel-level config when the check targets a funnel.
79
- if (breakdownArgs.type === 'funnelFrequency' || breakdownArgs.type === 'timeToConvert') {
80
- const targetSteps = breakdownArgs.steps
81
- || (breakdownArgs.fromEvent && breakdownArgs.toEvent
82
- ? [breakdownArgs.fromEvent, breakdownArgs.toEvent]
83
- : null);
84
- const matched = findMatchingFunnel(validatedFunnels, targetSteps);
85
- if (matched) {
86
- if (breakdownArgs.conversionWindowMs === undefined && Number.isFinite(matched.conversionWindowDays)) {
87
- breakdownArgs.conversionWindowMs = matched.conversionWindowDays * 86400000;
88
- }
89
- if (breakdownArgs.funnelOrder === undefined && matched.order) {
90
- breakdownArgs.funnelOrder = matched.order;
91
- }
92
- // v1.5.0: thread Funnel-level extension hints through to the verifier.
93
- if (breakdownArgs.reentry === undefined && matched.reentry !== undefined) {
94
- breakdownArgs.reentry = matched.reentry;
95
- }
96
- if (breakdownArgs.exclusionSteps === undefined && Array.isArray(matched.exclusionEvents) && matched.exclusionEvents.length) {
97
- breakdownArgs.exclusionSteps = matched.exclusionEvents.map(name => ({ event: name }));
98
- }
99
- // stepFilters: Record<number, { prop, op, value }> → mutate steps to attach where-clause.
100
- if (matched.stepFilters && breakdownArgs.steps && Array.isArray(breakdownArgs.steps)) {
101
- breakdownArgs.steps = breakdownArgs.steps.map((s, i) => {
102
- const filter = matched.stepFilters[i];
103
- if (!filter) return s;
104
- const stepObj = typeof s === 'string' ? { event: s } : { ...s };
105
- if (!stepObj.where) stepObj.where = filter;
106
- return stepObj;
107
- });
108
- }
109
- }
110
- }
132
+ const breakdownArgs = applyFunnelDefaults(check.breakdown, validatedFunnels, profiles);
111
133
  const rows = emulateBreakdown(events, breakdownArgs);
112
134
  const verdict = check.assert(rows, ctx);
113
135
  results.push({ name: check.name, pass: !!verdict.pass, detail: verdict.detail, rows });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.5.4",
3
+ "version": "1.6.1",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -25,6 +25,7 @@
25
25
  "!dungeons/user/",
26
26
  "!dungeons/capstone/",
27
27
  "scripts/",
28
+ "docs/",
28
29
  ".claude/skills/",
29
30
  "package.json",
30
31
  "README.md",
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Usage:
18
18
  * node scripts/extract-dungeon-schema.mjs <input.js> [output.json]
19
- * node scripts/extract-dungeon-schema.mjs dungeons/vertical/*.js # batch mode
19
+ * node scripts/extract-dungeon-schema.mjs dungeons/vertical/<name>/<name>.js ... # batch mode (shell glob OK)
20
20
  */
21
21
 
22
22
  import { writeFileSync } from 'fs';
@@ -29,7 +29,7 @@ const args = process.argv.slice(2);
29
29
 
30
30
  if (args.length === 0) {
31
31
  console.error('Usage: node scripts/extract-dungeon-schema.mjs <dungeon.js> [output.json]');
32
- console.error(' node scripts/extract-dungeon-schema.mjs dungeons/vertical/*.js');
32
+ console.error(" node scripts/extract-dungeon-schema.mjs 'dungeons/vertical/*/*.js'");
33
33
  process.exit(1);
34
34
  }
35
35
 
@@ -15,8 +15,8 @@ const CONCURRENCY = 10
15
15
  * Run multiple dungeons concurrently from the command line.
16
16
  *
17
17
  * Usage:
18
- * node scripts/run-many.mjs dungeons/vertical/gaming.js dungeons/vertical/media.js dungeons/vertical/food-delivery.js
19
- * node scripts/run-many.mjs dungeons/vertical/*.js
18
+ * node scripts/run-many.mjs dungeons/vertical/gaming/gaming.js dungeons/vertical/media/media.js
19
+ * (shell globs expand to file arguments, e.g. dungeons/vertical/<name> folders)
20
20
  */
21
21
 
22
22
  const args = process.argv.slice(2);
@@ -13,8 +13,8 @@
13
13
  * node scripts/verify-runner.mjs <dungeon-path> [run-name] [--small]
14
14
  *
15
15
  * Examples:
16
- * node scripts/verify-runner.mjs dungeons/vertical/gaming.js verify-gaming
17
- * node scripts/verify-runner.mjs dungeons/vertical/gaming.js verify-gaming --small
16
+ * node scripts/verify-runner.mjs dungeons/vertical/gaming/gaming.js verify-gaming
17
+ * node scripts/verify-runner.mjs dungeons/vertical/gaming/gaming.js verify-gaming --small
18
18
  */
19
19
  import generate from '../index.js';
20
20
  import path from 'path';