@viccydev/pi-fpa 0.5.0 → 0.6.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.
@@ -3,17 +3,27 @@ import { lstat, readFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  import { resolveDashboardDir } from "./publisher.ts";
6
+ import {
7
+ validateProjectorProvenance,
8
+ validateProjectorRuntime,
9
+ type DashboardProjectorProvenance,
10
+ type DashboardProjectorRuntime,
11
+ } from "./provenance.ts";
6
12
  import { stableJson } from "../fpa-artifacts/store.ts";
7
13
  import { DASHBOARD_WIDGET_TYPES, validateDatasetForWidget, type DashboardWidgetType } from "./schema.ts";
14
+ import { validateActualsSnapshot } from "./source.ts";
8
15
 
9
- const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
10
- const GENERATION_RECEIPT_RE = /^build-receipt\.([a-f0-9]{12})\.([a-f0-9]{12})\.json$/;
16
+ const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,191}\.json$/;
17
+ const GENERATION_RECEIPT_RE = /^build-receipt\.([a-f0-9]{64})\.([a-f0-9]{64})\.json$/;
11
18
 
12
19
  export interface DashboardStatus {
13
20
  dashboard_exists: boolean;
14
21
  dashboard_dir: string;
15
22
  generation_id?: string;
16
23
  updated_at?: string;
24
+ projector?: DashboardProjectorProvenance;
25
+ runtime?: DashboardProjectorRuntime;
26
+ provenance_status?: "verified" | "legacy_missing" | "invalid";
17
27
  widget_count: number;
18
28
  diagnostics: Array<{ target: string; message: string }>;
19
29
  }
@@ -50,6 +60,9 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
50
60
  return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics: [{ target: "manifest.json", message: "unsupported dashboard kind/schema or widgets shape" }] };
51
61
  }
52
62
  const receiptDatasets = new Map<string, string>();
63
+ let projector: DashboardProjectorProvenance | undefined;
64
+ let runtime: DashboardProjectorRuntime | undefined;
65
+ let provenanceStatus: DashboardStatus["provenance_status"];
53
66
  const receiptFilename = typeof source.buildReceipt === "string" && DATASET_FILE_RE.test(source.buildReceipt)
54
67
  ? source.buildReceipt
55
68
  : "build-receipt.json";
@@ -62,6 +75,23 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
62
75
  if (receiptSource.kind !== "fpa.dashboard.build" || receiptSource.schema_version !== 1) {
63
76
  throw new Error("has an unsupported build receipt kind or schema");
64
77
  }
78
+ if (receiptSource.projector === undefined && receiptSource.runtime === undefined) {
79
+ provenanceStatus = "legacy_missing";
80
+ } else {
81
+ provenanceStatus = "verified";
82
+ try {
83
+ projector = validateProjectorProvenance(receiptSource.projector);
84
+ } catch (error) {
85
+ provenanceStatus = "invalid";
86
+ diagnostics.push({ target: `${receiptFilename} projector`, message: error instanceof Error ? error.message : String(error) });
87
+ }
88
+ try {
89
+ runtime = validateProjectorRuntime(receiptSource.runtime);
90
+ } catch (error) {
91
+ provenanceStatus = "invalid";
92
+ diagnostics.push({ target: `${receiptFilename} runtime`, message: error instanceof Error ? error.message : String(error) });
93
+ }
94
+ }
65
95
  if (typeof source.generationId === "string" && receiptSource.generation_id !== source.generationId) {
66
96
  diagnostics.push({ target: receiptFilename, message: "generation does not match manifest.json" });
67
97
  }
@@ -70,15 +100,33 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
70
100
  }
71
101
  const receiptNameMatch = receiptFilename.match(GENERATION_RECEIPT_RE);
72
102
  if (receiptNameMatch) {
73
- if (typeof source.generationId !== "string" || receiptNameMatch[1] !== source.generationId.slice(0, 12)) {
74
- diagnostics.push({ target: receiptFilename, message: "filename generation prefix does not match manifest.json" });
103
+ if (typeof source.generationId !== "string" || receiptNameMatch[1] !== source.generationId) {
104
+ diagnostics.push({ target: receiptFilename, message: "filename generation digest does not match manifest.json" });
75
105
  }
76
106
  const receiptDigest = createHash("sha256").update(stableJson(receipt)).digest("hex");
77
- if (receiptNameMatch[2] !== receiptDigest.slice(0, 12)) {
107
+ if (receiptNameMatch[2] !== receiptDigest) {
78
108
  diagnostics.push({ target: receiptFilename, message: "receipt content digest does not match its filename" });
79
109
  }
80
110
  }
81
111
  if (!Array.isArray(receiptSource.datasets)) throw new Error("datasets must be an array");
112
+ if (receiptSource.source && typeof receiptSource.source === "object" && !Array.isArray(receiptSource.source)) {
113
+ const actualsRef = (receiptSource.source as Record<string, unknown>).actuals_snapshot_ref;
114
+ if (actualsRef !== undefined) {
115
+ if (typeof actualsRef !== "string" || !/^[a-f0-9]{64}$/.test(actualsRef)) {
116
+ diagnostics.push({ target: `${receiptFilename} source.actuals_snapshot_ref`, message: "must be a sha256 digest" });
117
+ } else {
118
+ try {
119
+ const snapshot = await readJson(join(dashboardDir, "actuals", `${actualsRef}.json`), 2 * 1024 * 1024);
120
+ const validated = validateActualsSnapshot(snapshot);
121
+ if (createHash("sha256").update(stableJson(validated)).digest("hex") !== actualsRef) {
122
+ diagnostics.push({ target: `actuals/${actualsRef}.json`, message: "Actuals snapshot content digest does not match its immutable ref" });
123
+ }
124
+ } catch (error) {
125
+ diagnostics.push({ target: `actuals/${actualsRef}.json`, message: error instanceof Error ? error.message : String(error) });
126
+ }
127
+ }
128
+ }
129
+ }
82
130
  for (const [index, raw] of receiptSource.datasets.entries()) {
83
131
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
84
132
  diagnostics.push({ target: `${receiptFilename} datasets[${index}]`, message: "must be an object" });
@@ -148,6 +196,9 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
148
196
  dashboard_dir: dashboardDir,
149
197
  ...(typeof source.generationId === "string" ? { generation_id: source.generationId } : {}),
150
198
  ...(typeof source.updatedAt === "string" ? { updated_at: source.updatedAt } : {}),
199
+ ...(projector ? { projector } : {}),
200
+ ...(runtime ? { runtime } : {}),
201
+ ...(provenanceStatus ? { provenance_status: provenanceStatus } : {}),
151
202
  widget_count: source.widgets.length,
152
203
  diagnostics,
153
204
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -16,20 +16,30 @@
16
16
  "access": "public",
17
17
  "registry": "https://registry.npmjs.org"
18
18
  },
19
+ "engines": {
20
+ "node": ">=22.19"
21
+ },
19
22
  "files": [
20
23
  "README.md",
24
+ "bin",
21
25
  "prompts",
22
26
  "skills",
23
27
  "extensions"
24
28
  ],
29
+ "bin": {
30
+ "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
31
+ },
25
32
  "scripts": {
26
- "test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
33
+ "test": "node tests/package-structure.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/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.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",
27
34
  "test:structure": "node tests/package-structure.test.mjs",
28
- "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
29
- "test:loader": "node tests/pi-loader-smoke.mjs",
35
+ "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/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.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",
36
+ "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
30
37
  "test:live": "node tests/live-smoke.mjs",
31
38
  "pack:check": "npm pack --dry-run"
32
39
  },
40
+ "dependencies": {
41
+ "jiti": "2.7.0"
42
+ },
33
43
  "peerDependencies": {
34
44
  "@earendil-works/pi-ai": "*",
35
45
  "@earendil-works/pi-coding-agent": "*",
@@ -29,7 +29,7 @@ If any requirement is missing, return `blocked` and make no external call. Never
29
29
  4. Present or record the dry-run result when the adapter supports it.
30
30
  5. Execute only the approved mutation set.
31
31
  6. Read back the resulting state and reconcile it with the requested state.
32
- 7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/execution_receipt.input.json`, then commit it with `fpa_artifact_commit` using `artifact_path`.
32
+ 7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/execution_receipt.input.json`, then commit it with `fpa_artifact_commit` using `artifact_path` and the exact `scope_id`, `cycle_id`, and approved Forecast `upstream_refs` entry returned by planning. Never validate against a mutable current pointer.
33
33
 
34
34
  ## Stop conditions
35
35
 
@@ -21,12 +21,17 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
21
21
  ## Procedure
22
22
 
23
23
  1. Verify approval and artifact lineage.
24
- 2. Lock the approved allocation and all approval conditions.
24
+ 2. Lock the approved allocation, accountable owner for each slice when known, and all approval conditions.
25
25
  3. Recalculate future-period operating outcomes from that allocation using the declared model.
26
26
  4. Provide downside, base, and upside values for each supported KPI.
27
27
  5. Reconcile allocation totals, formulas, and cross-metric identities.
28
28
  6. Write the forecast plan to `artifacts/forecast_plan.json` using [artifact-contract.md](references/artifact-contract.md).
29
- 7. Derive the artifact with `fpa_forecast_compose`, then freeze it with `fpa_artifact_commit`.
29
+ 7. Derive the artifact with `fpa_forecast_compose`, then freeze it with
30
+ `fpa_artifact_commit` and an assigned context containing the explicit
31
+ `scope_id`, `cycle_id`, and `forecast_role`. Use `original` only when the
32
+ forecast is frozen no later than period start; use `eac` for an in-period
33
+ reforecast and `next_plan` when it is the approved direct successor of the
34
+ active cycle.
30
35
 
31
36
  ## Write the decision, not the arithmetic
32
37
 
@@ -21,6 +21,7 @@ approved_allocation:
21
21
  baseline_spend: number | null
22
22
  approved_spend: number
23
23
  action: stop | decrease | hold | increase | explore
24
+ owner: string # optional accountable operator
24
25
  forecast_by_slice:
25
26
  - app_id: string
26
27
  store: string
@@ -78,6 +79,7 @@ slices:
78
79
  baseline_spend: number | null
79
80
  approved_spend: number
80
81
  action: stop | decrease | hold | increase | explore
82
+ owner: string # optional; shown as the accountable operator on the CEO dashboard
81
83
  roas: {downside: number|null, base: number|null, upside: number|null}
82
84
  consolidated_extra_metrics: # optional; measured portfolio series only
83
85
  metric_name: {downside, base, upside, unit}
@@ -11,6 +11,10 @@ Load `$fpa-apply-core-rules` first. Follow [dashboard-policy.md](references/dash
11
11
 
12
12
  Require a committed `approved_cycle_forecast` for the target project. Treat a committed `execution_receipt` as optional execution evidence; never describe manual reported execution as independently verified.
13
13
 
14
+ For an assigned ledger cycle, pass `scope_id`, `cycle_id`, and the exact
15
+ `forecast_ref`; pass exact execution/forward refs when available. Never provide
16
+ only part of this tuple or fall back to a mutable current pointer.
17
+
14
18
  ## Procedure
15
19
 
16
20
  1. Call `fpa_dashboard_status` to inspect the current generation and diagnostics.
@@ -37,4 +41,8 @@ answer, and do not describe it as "the period has no data yet".
37
41
  - Never fabricate Actuals, replace missing values with zero, or average row-level ratios.
38
42
  - Do not edit the approved forecast during projection.
39
43
  - If inputs change between preview and publish, preview again rather than bypassing the fingerprint check.
40
- - This skill performs one bounded refresh; it does not continuously monitor the dashboard.
44
+ - This skill performs one bounded human-requested refresh. Assigned artifact
45
+ commits and Actuals watermark changes are monitored separately by the durable
46
+ package coordinator running in the package-owned `fpa-dashboard-worker`
47
+ process (never in the read-only Web host); inspect or drain that queue with
48
+ `fpa_dashboard_refresh_queue` rather than recreating its writes manually.