@viccydev/pi-fpa 0.9.4 → 0.9.6

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.
@@ -46,6 +46,15 @@ export interface BreakdownRule {
46
46
  byDimension: Record<string, string>;
47
47
  }
48
48
 
49
+ export interface CsvSourceDef {
50
+ /** File name inside the configured finance CSV root. */
51
+ file: string;
52
+ /** "month" when the date column holds YYYY-MM rather than YYYY-MM-DD. */
53
+ dateFormat?: "month" | "date";
54
+ /** Measure name -> how its column aggregates within a group. */
55
+ aggregates: Record<string, "sum" | "avg" | "min" | "max" | "last">;
56
+ }
57
+
49
58
  export interface DatasetDef {
50
59
  id: string;
51
60
  table: string;
@@ -59,6 +68,14 @@ export interface DatasetDef {
59
68
  eventName?: EventNameExpansion;
60
69
  /** Extra FROM clause fragments (lateral joins) required by the measures. */
61
70
  lateral?: string;
71
+ /**
72
+ * Where the rows come from. Defaults to "sql" so every existing dataset is
73
+ * unchanged. A "csv" dataset shares the same registry, query spec, derived
74
+ * metrics, and query receipt — only the executor differs, so swapping the
75
+ * finance import for a real one later touches nothing above this line.
76
+ */
77
+ source?: "sql" | "csv";
78
+ csv?: CsvSourceDef;
62
79
  }
63
80
 
64
81
  const AF_METRIC_FIELDS: Record<string, string> = {
@@ -255,6 +272,313 @@ export const DATASETS: DatasetDef[] = [
255
272
  measures: { event_count: "sum((e.v)::numeric)" },
256
273
  },
257
274
  },
275
+
276
+ // ------------------------------------------------------------------
277
+ // Finance datasets, currently CSV-backed.
278
+ //
279
+ // The UA mart answers "what did we spend and earn on advertising". These
280
+ // answer "what does the business look like" — cash, cost structure, unit
281
+ // economics. Declared here rather than in a separate registry so the same
282
+ // vetted-names rule holds: the model picks a name, code does the
283
+ // arithmetic, and swapping CSV for a real import changes only the executor.
284
+ // ------------------------------------------------------------------
285
+ {
286
+ id: "pnl_monthly",
287
+ table: "pnl_monthly.csv",
288
+ dateColumn: "month",
289
+ description: "Portfolio monthly P&L bridge: billings through to net free cash flow",
290
+ source: "csv",
291
+ csv: {
292
+ file: "pnl_monthly.csv",
293
+ dateFormat: "month",
294
+ aggregates: {
295
+ gross_billings: "sum", refunds: "sum", platform_fee: "sum", net_proceeds: "sum",
296
+ ua_spend: "sum", contribution_margin: "sum", tax: "sum", opex_total: "sum", net_fcf: "sum",
297
+ },
298
+ },
299
+ dimensions: [
300
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
301
+ { name: "close_status", sql: "close_status", description: "closed or open — an open month has no final tax, opex, or FCF" },
302
+ { name: "data_completeness", sql: "data_completeness", description: "complete, or partial when a source app row was missing" },
303
+ ],
304
+ measures: [
305
+ { name: "gross_billings", sql: "gross_billings", description: "Store gross billings", unit: "usd" },
306
+ { name: "refunds", sql: "refunds", description: "Refunds", unit: "usd" },
307
+ { name: "platform_fee", sql: "platform_fee", description: "Store commission", unit: "usd" },
308
+ { name: "net_proceeds", sql: "net_proceeds", description: "Net proceeds after refunds and commission", unit: "usd" },
309
+ { name: "ua_spend", sql: "ua_spend", description: "UA spend", unit: "usd" },
310
+ { name: "contribution_margin", sql: "contribution_margin", description: "Net proceeds less UA spend", unit: "usd" },
311
+ { name: "tax", sql: "tax", description: "Tax", unit: "usd" },
312
+ { name: "opex_total", sql: "opex_total", description: "Operating expenses (company level, not attributable to an app)", unit: "usd" },
313
+ { name: "net_fcf", sql: "net_fcf", description: "Net free cash flow", unit: "usd" },
314
+ ],
315
+ derived: [
316
+ { name: "fcf_margin", description: "net_fcf / net_proceeds", numerator: "net_fcf", denominator: "net_proceeds", unit: "ratio" },
317
+ { name: "roas_blended", description: "net_proceeds / ua_spend", numerator: "net_proceeds", denominator: "ua_spend", unit: "ratio" },
318
+ ],
319
+ notes: [
320
+ "An open month has NULL tax, opex_total, and net_fcf — the books are not closed, so those figures do not exist yet.",
321
+ "data_completeness=partial means a source app row was missing and the portfolio total is understated; report it as incomplete rather than as a clean aggregate.",
322
+ ],
323
+ },
324
+ {
325
+ id: "pnl_by_app_monthly",
326
+ table: "pnl_by_app_monthly.csv",
327
+ dateColumn: "month",
328
+ description: "Per-app monthly contribution: billings through to contribution margin",
329
+ source: "csv",
330
+ csv: {
331
+ file: "pnl_by_app_monthly.csv",
332
+ dateFormat: "month",
333
+ aggregates: {
334
+ gross_billings: "sum", refunds: "sum", platform_fee: "sum",
335
+ net_proceeds: "sum", ua_spend: "sum", contribution_margin: "sum",
336
+ },
337
+ },
338
+ dimensions: [
339
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
340
+ { name: "app_id", sql: "app_id", description: "Internal app code, joins to ua_spend.app_code" },
341
+ { name: "app_name", sql: "app_name", description: "Product name" },
342
+ { name: "store", sql: "store", description: "ios or android" },
343
+ { name: "close_status", sql: "close_status", description: "closed or open" },
344
+ ],
345
+ measures: [
346
+ { name: "gross_billings", sql: "gross_billings", description: "Store gross billings", unit: "usd" },
347
+ { name: "refunds", sql: "refunds", description: "Refunds", unit: "usd" },
348
+ { name: "platform_fee", sql: "platform_fee", description: "Store commission", unit: "usd" },
349
+ { name: "net_proceeds", sql: "net_proceeds", description: "Net proceeds", unit: "usd" },
350
+ { name: "ua_spend", sql: "ua_spend", description: "UA spend", unit: "usd" },
351
+ { name: "contribution_margin", sql: "contribution_margin", description: "Net proceeds less UA spend", unit: "usd" },
352
+ ],
353
+ derived: [
354
+ { name: "contribution_rate", description: "contribution_margin / net_proceeds", numerator: "contribution_margin", denominator: "net_proceeds", unit: "ratio" },
355
+ { name: "app_roas", description: "net_proceeds / ua_spend", numerator: "net_proceeds", denominator: "ua_spend", unit: "ratio" },
356
+ ],
357
+ notes: [
358
+ "Stops at contribution_margin on purpose: opex is a company-level cost and allocating it per app would manufacture precision that does not exist. Only the portfolio has net_fcf.",
359
+ ],
360
+ },
361
+ {
362
+ id: "opex_monthly",
363
+ table: "opex_monthly.csv",
364
+ dateColumn: "month",
365
+ description: "Operating expense by category",
366
+ source: "csv",
367
+ csv: { file: "opex_monthly.csv", dateFormat: "month", aggregates: { amount_usd: "sum", headcount: "last" } },
368
+ dimensions: [
369
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
370
+ { name: "category", sql: "category", description: "personnel, infrastructure, third_party_services, marketing_non_ua, general_admin" },
371
+ { name: "close_status", sql: "close_status", description: "closed or open" },
372
+ ],
373
+ measures: [
374
+ { name: "amount_usd", sql: "amount_usd", description: "Expense amount", unit: "usd" },
375
+ { name: "headcount", sql: "headcount", description: "Headcount (personnel rows only)", unit: "count" },
376
+ ],
377
+ derived: [],
378
+ notes: ["Categories sum to pnl_monthly.opex_total for the same closed month."],
379
+ },
380
+ {
381
+ id: "cash_position_monthly",
382
+ table: "cash_position_monthly.csv",
383
+ dateColumn: "month",
384
+ description: "Cash roll-forward and runway",
385
+ source: "csv",
386
+ csv: {
387
+ file: "cash_position_monthly.csv",
388
+ dateFormat: "month",
389
+ aggregates: {
390
+ opening_cash: "last", net_fcf: "sum", financing_inflow: "sum",
391
+ other_movements: "sum", closing_cash: "last", monthly_fixed_outflow: "last", runway_months: "last",
392
+ },
393
+ },
394
+ dimensions: [
395
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
396
+ { name: "close_status", sql: "close_status", description: "closed or open" },
397
+ ],
398
+ measures: [
399
+ { name: "opening_cash", sql: "opening_cash", description: "Opening cash", unit: "usd" },
400
+ { name: "net_fcf", sql: "net_fcf", description: "Net free cash flow", unit: "usd" },
401
+ { name: "financing_inflow", sql: "financing_inflow", description: "Financing inflow", unit: "usd" },
402
+ { name: "other_movements", sql: "other_movements", description: "Other cash movements", unit: "usd" },
403
+ { name: "closing_cash", sql: "closing_cash", description: "Closing cash", unit: "usd" },
404
+ { name: "monthly_fixed_outflow", sql: "monthly_fixed_outflow", description: "Opex plus UA spend", unit: "usd" },
405
+ { name: "runway_months", sql: "runway_months", description: "Closing cash divided by monthly fixed outflow", unit: "ratio" },
406
+ ],
407
+ derived: [],
408
+ notes: [
409
+ "runway_months is cash over fixed outflow, not over net burn: burn-based runway is NULL or infinite while FCF is positive, which tells the reader nothing.",
410
+ "Balances are point-in-time. Aggregating several months returns the last month's balance, never a sum.",
411
+ ],
412
+ },
413
+ {
414
+ id: "cohort_ltv",
415
+ table: "cohort_ltv.csv",
416
+ dateColumn: "install_month",
417
+ description: "Install cohorts by app, store, and channel: CAC, revenue curve, LTV, payback",
418
+ source: "csv",
419
+ csv: {
420
+ file: "cohort_ltv.csv",
421
+ dateFormat: "month",
422
+ aggregates: {
423
+ installs: "sum", ua_spend: "sum", cac: "avg",
424
+ revenue_d0: "sum", revenue_d7: "sum", revenue_d30: "sum", revenue_d90: "sum", revenue_d180: "sum",
425
+ ltv_180: "avg", payback_days: "avg", roas_d180: "avg",
426
+ },
427
+ },
428
+ dimensions: [
429
+ { name: "install_month", sql: "install_month", description: "Install cohort month (YYYY-MM)" },
430
+ { name: "app_id", sql: "app_id", description: "Internal app code" },
431
+ { name: "store", sql: "store", description: "ios or android" },
432
+ { name: "channel_group", sql: "channel_group", description: "Channel, same values as ua_spend.media_source" },
433
+ { name: "maturity", sql: "maturity", description: "mature, partial, or immature" },
434
+ ],
435
+ measures: [
436
+ { name: "installs", sql: "installs", description: "Attributed installs", unit: "count" },
437
+ { name: "ua_spend", sql: "ua_spend", description: "Cohort UA spend", unit: "usd" },
438
+ { name: "cac", sql: "cac", description: "Cost per acquisition", unit: "usd" },
439
+ { name: "revenue_d0", sql: "revenue_d0", description: "Cumulative revenue at day 0", unit: "usd" },
440
+ { name: "revenue_d7", sql: "revenue_d7", description: "Cumulative revenue at day 7", unit: "usd" },
441
+ { name: "revenue_d30", sql: "revenue_d30", description: "Cumulative revenue at day 30", unit: "usd" },
442
+ { name: "revenue_d90", sql: "revenue_d90", description: "Cumulative revenue at day 90", unit: "usd" },
443
+ { name: "revenue_d180", sql: "revenue_d180", description: "Cumulative revenue at day 180", unit: "usd" },
444
+ { name: "ltv_180", sql: "ltv_180", description: "180-day LTV per install", unit: "usd" },
445
+ { name: "payback_days", sql: "payback_days", description: "Days to recover CAC", unit: "count" },
446
+ { name: "roas_d180", sql: "roas_d180", description: "180-day ROAS", unit: "ratio" },
447
+ ],
448
+ derived: [
449
+ { name: "ltv_to_cac", description: "ltv_180 / cac", numerator: "ltv_180", denominator: "cac", unit: "ratio" },
450
+ ],
451
+ notes: [
452
+ "Immature cohorts carry NULL for the horizons they have not reached. A zero there would read as a failed cohort and get cut.",
453
+ ],
454
+ },
455
+ {
456
+ id: "subscription_metrics",
457
+ table: "subscription_metrics.csv",
458
+ dateColumn: "month",
459
+ description: "Subscription counts, renewal rates by month of tenure, and refund rate",
460
+ source: "csv",
461
+ csv: {
462
+ file: "subscription_metrics.csv",
463
+ dateFormat: "month",
464
+ aggregates: {
465
+ new_subscriptions: "sum", active_subscriptions: "sum",
466
+ renewal_rate_m1: "avg", renewal_rate_m2: "avg", renewal_rate_m3: "avg", refund_rate: "avg",
467
+ },
468
+ },
469
+ dimensions: [
470
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
471
+ { name: "app_id", sql: "app_id", description: "Internal app code" },
472
+ { name: "store", sql: "store", description: "ios or android" },
473
+ { name: "close_status", sql: "close_status", description: "closed or open" },
474
+ ],
475
+ measures: [
476
+ { name: "new_subscriptions", sql: "new_subscriptions", description: "New subscriptions", unit: "count" },
477
+ { name: "active_subscriptions", sql: "active_subscriptions", description: "Active subscriptions", unit: "count" },
478
+ { name: "renewal_rate_m1", sql: "renewal_rate_m1", description: "Month-1 renewal rate", unit: "ratio" },
479
+ { name: "renewal_rate_m2", sql: "renewal_rate_m2", description: "Month-2 renewal rate", unit: "ratio" },
480
+ { name: "renewal_rate_m3", sql: "renewal_rate_m3", description: "Month-3 renewal rate", unit: "ratio" },
481
+ { name: "refund_rate", sql: "refund_rate", description: "Refund rate", unit: "ratio" },
482
+ ],
483
+ derived: [],
484
+ notes: [
485
+ "Renewal rates are averaged across the grouped rows, never summed — a rate has no meaningful sum.",
486
+ ],
487
+ },
488
+ {
489
+ id: "forecast_vs_actual",
490
+ table: "forecast_vs_actual.csv",
491
+ dateColumn: "month",
492
+ description: "Forecast against actual by month, metric, and forecast vintage",
493
+ source: "csv",
494
+ csv: {
495
+ file: "forecast_vs_actual.csv",
496
+ dateFormat: "month",
497
+ aggregates: {
498
+ forecast_value: "sum", actual_value: "sum", variance: "sum",
499
+ variance_pct: "avg", abs_pct_error: "avg",
500
+ },
501
+ },
502
+ dimensions: [
503
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
504
+ { name: "metric", sql: "metric", description: "Which figure was forecast" },
505
+ { name: "forecast_age_months", sql: "forecast_age_months", description: "How long before the month the forecast was made" },
506
+ { name: "forecast_version", sql: "forecast_version", description: "Forecast identity" },
507
+ ],
508
+ measures: [
509
+ { name: "forecast_value", sql: "forecast_value", description: "Forecast figure", unit: "usd" },
510
+ { name: "actual_value", sql: "actual_value", description: "Actual figure", unit: "usd" },
511
+ { name: "variance", sql: "variance", description: "Actual less forecast", unit: "usd" },
512
+ { name: "variance_pct", sql: "variance_pct", description: "Variance over forecast", unit: "ratio" },
513
+ { name: "abs_pct_error", sql: "abs_pct_error", description: "Absolute percentage error", unit: "ratio" },
514
+ ],
515
+ derived: [],
516
+ notes: [
517
+ "Compare like vintages: a 1-month-ahead forecast and a 3-month-ahead one are different claims about the same month.",
518
+ "abs_pct_error averages into MAPE; it must never be summed.",
519
+ ],
520
+ },
521
+ {
522
+ id: "variance_attribution",
523
+ table: "variance_attribution.csv",
524
+ dateColumn: "month",
525
+ description: "Named causes a month's variance decomposes into",
526
+ source: "csv",
527
+ csv: {
528
+ file: "variance_attribution.csv",
529
+ dateFormat: "month",
530
+ aggregates: { impact_usd: "sum", impact_share: "sum", confidence: "avg" },
531
+ },
532
+ dimensions: [
533
+ { name: "month", sql: "month", description: "Calendar month (YYYY-MM)" },
534
+ { name: "metric", sql: "metric", description: "Which figure the variance belongs to" },
535
+ { name: "factor", sql: "factor", description: "The named cause" },
536
+ { name: "category", sql: "category", description: "Cause category; 'unexplained' is the residual" },
537
+ { name: "confidence_band", sql: "confidence_band", description: "high, medium, or low" },
538
+ ],
539
+ measures: [
540
+ { name: "impact_usd", sql: "impact_usd", description: "Signed contribution to the variance", unit: "usd" },
541
+ { name: "impact_share", sql: "impact_share", description: "Share of the total variance", unit: "ratio" },
542
+ { name: "confidence", sql: "confidence", description: "Attribution confidence", unit: "ratio" },
543
+ ],
544
+ derived: [],
545
+ notes: [
546
+ "Factors sum to the month's variance. A category of 'unexplained' is the part no factor accounts for — report it rather than dropping it, or the decomposition will read as complete when it is not.",
547
+ ],
548
+ },
549
+ {
550
+ id: "baseline_assumptions",
551
+ table: "baseline_assumptions.csv",
552
+ dateColumn: "detected_at",
553
+ description: "Model baseline assumptions and the corrections detected against them",
554
+ source: "csv",
555
+ csv: {
556
+ file: "baseline_assumptions.csv",
557
+ dateFormat: "date",
558
+ aggregates: { current_value: "last", suggested_value: "last", estimated_impact_usd_12m: "sum" },
559
+ },
560
+ dimensions: [
561
+ { name: "assumption_id", sql: "assumption_id", description: "Assumption identity" },
562
+ { name: "assumption_name", sql: "assumption_name", description: "Human-readable name" },
563
+ { name: "scope", sql: "scope", description: "What the assumption applies to" },
564
+ { name: "unit", sql: "unit", description: "Value unit" },
565
+ { name: "detection_basis", sql: "detection_basis", description: "Why a correction was proposed" },
566
+ { name: "affected_models", sql: "affected_models", description: "Models the change would move" },
567
+ { name: "approval_status", sql: "approval_status", description: "current, pending, or approved" },
568
+ { name: "approver_role", sql: "approver_role", description: "Who must approve a change" },
569
+ { name: "detected_at", sql: "detected_at", description: "When the drift was detected" },
570
+ { name: "effective_from", sql: "effective_from", description: "When an approved change takes effect" },
571
+ ],
572
+ measures: [
573
+ { name: "current_value", sql: "current_value", description: "Baseline in force", unit: "ratio" },
574
+ { name: "suggested_value", sql: "suggested_value", description: "Proposed baseline; null when none is proposed", unit: "ratio" },
575
+ { name: "estimated_impact_usd_12m", sql: "estimated_impact_usd_12m", description: "Estimated 12-month impact of adopting the proposal", unit: "usd" },
576
+ ],
577
+ derived: [],
578
+ notes: [
579
+ "A pending correction is a proposal, not a fact. It must never be applied to a projection before its approver has accepted it.",
580
+ ],
581
+ },
258
582
  ];
259
583
 
260
584
  export const DATASET_IDS = DATASETS.map((d) => d.id);
@@ -1,4 +1,6 @@
1
1
  import { computeDerived, round, toNumber, type NumericLike } from "./calc.ts";
2
+ import { runCsvQuery } from "./csvsource.ts";
3
+ import { getDataset } from "./registry.ts";
2
4
  import { buildQuery, type QuerySpec } from "./sql.ts";
3
5
  import { runQuery, type SqlRow } from "./supabase.ts";
4
6
 
@@ -15,7 +17,25 @@ export function shapeQueryRows(
15
17
  });
16
18
  }
17
19
 
18
- export async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal) {
20
+ export async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal, workspaceRoot?: string) {
21
+ // Route on the dataset's declared source. Everything after this line —
22
+ // rounding, derived metrics, null propagation — is identical either way, so
23
+ // a metric means the same thing regardless of where its rows came from.
24
+ if (getDataset(spec.dataset).source === "csv") {
25
+ const result = await runCsvQuery(spec, process.env, workspaceRoot);
26
+ const derived = result.dataset.derived.filter((def) => spec.metrics.includes(def.name));
27
+ return {
28
+ built: {
29
+ sql: `-- csv:${result.dataset.csv?.file ?? result.dataset.id}`,
30
+ dataset: result.dataset,
31
+ measures: result.measures,
32
+ derived,
33
+ dimensions: result.dimensions,
34
+ notes: [...result.dataset.notes, ...result.notes],
35
+ } as ReturnType<typeof buildQuery>,
36
+ rows: shapeQueryRows(result.rows, result.measures, derived),
37
+ };
38
+ }
19
39
  const built = buildQuery(spec);
20
40
  const rows = await runQuery(built.sql, { signal });
21
41
  return { built, rows: shapeQueryRows(rows, built.measures, built.derived) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
6
6
  "license": "UNLICENSED",
@@ -34,9 +34,9 @@
34
34
  "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
35
35
  },
36
36
  "scripts": {
37
- "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs tests/graph-installer.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/forecast-finalize.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
37
+ "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs tests/graph-installer.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/forecast-finalize.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/decision-ledger.test.mjs tests/csv-source.test.mjs tests/finance-projector.test.mjs tests/calibration-projector.test.mjs tests/compat-publisher-split.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
38
38
  "test:structure": "node tests/package-structure.test.mjs",
39
- "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/forecast-finalize.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs",
39
+ "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/forecast-finalize.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/decision-ledger.test.mjs tests/csv-source.test.mjs tests/finance-projector.test.mjs tests/calibration-projector.test.mjs tests/compat-publisher-split.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs",
40
40
  "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
41
41
  "test:live": "node tests/live-smoke.mjs",
42
42
  "pack:check": "npm pack --dry-run"