@viccydev/pi-fpa 0.5.0 → 0.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.
@@ -3,21 +3,152 @@ 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";
27
+ receipt_summary?: DashboardReceiptSummary;
28
+ lineage_verification?: DashboardLineageVerification;
17
29
  widget_count: number;
18
30
  diagnostics: Array<{ target: string; message: string }>;
19
31
  }
20
32
 
33
+ export interface DashboardLineageExpectation {
34
+ forecast_role: "original" | "eac" | "next_plan";
35
+ forecast_ref: string;
36
+ }
37
+
38
+ export interface DashboardLineageVerification {
39
+ matches: boolean;
40
+ reason: string | null;
41
+ }
42
+
43
+ export interface DashboardReceiptSummary {
44
+ source: {
45
+ project_name?: string;
46
+ scope_id?: string;
47
+ cycle_id?: string;
48
+ forecast_version: string;
49
+ forecast_ref?: string;
50
+ forecast_role?: "original" | "eac" | "next_plan";
51
+ next_forecast_ref?: string;
52
+ forward_forecast_refs: string[];
53
+ execution_ref?: string;
54
+ data_as_of: string;
55
+ };
56
+ actuals: {
57
+ period_complete: boolean | null;
58
+ reconciled: boolean | null;
59
+ };
60
+ operating: {
61
+ current_forecast_version: string | null;
62
+ execution_status: string | null;
63
+ next_cycle_status: string | null;
64
+ next_forecast_version: string | null;
65
+ };
66
+ warnings: string[];
67
+ }
68
+
69
+ function record(value: unknown): Record<string, unknown> | null {
70
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
71
+ }
72
+
73
+ function optionalBoundedString(value: unknown, maxLength = 4096): string | undefined {
74
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength ? value : undefined;
75
+ }
76
+
77
+ function buildReceiptSummary(receipt: Record<string, unknown>): DashboardReceiptSummary | undefined {
78
+ const source = record(receipt.source);
79
+ const forecastVersion = optionalBoundedString(source?.forecast_version, 256);
80
+ const dataAsOf = optionalBoundedString(source?.data_as_of, 256);
81
+ if (!source || !forecastVersion || !dataAsOf) return undefined;
82
+ const role = source.forecast_role;
83
+ const forecastRole = role === "original" || role === "eac" || role === "next_plan" ? role : undefined;
84
+ const forwardForecastRefs = Array.isArray(source.forward_forecast_refs)
85
+ ? source.forward_forecast_refs.filter((value): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)).slice(0, 6)
86
+ : [];
87
+ const evidence = record(source.actuals_snapshot_evidence);
88
+ const projection = record(receipt.operating_projection);
89
+ const currentCycle = record(projection?.current_cycle);
90
+ const execution = record(currentCycle?.execution);
91
+ const nextCycle = record(projection?.next_cycle);
92
+ const warnings = Array.isArray(receipt.warnings)
93
+ ? receipt.warnings.filter((value): value is string => typeof value === "string" && value.length <= 4096).slice(0, 128)
94
+ : [];
95
+ return {
96
+ source: {
97
+ ...(optionalBoundedString(source.project_name, 256) ? { project_name: source.project_name as string } : {}),
98
+ ...(optionalBoundedString(source.scope_id, 256) ? { scope_id: source.scope_id as string } : {}),
99
+ ...(optionalBoundedString(source.cycle_id, 256) ? { cycle_id: source.cycle_id as string } : {}),
100
+ forecast_version: forecastVersion,
101
+ ...(optionalBoundedString(source.forecast_ref, 64) && /^[a-f0-9]{64}$/.test(source.forecast_ref as string) ? { forecast_ref: source.forecast_ref as string } : {}),
102
+ ...(forecastRole ? { forecast_role: forecastRole } : {}),
103
+ ...(optionalBoundedString(source.next_forecast_ref, 64) && /^[a-f0-9]{64}$/.test(source.next_forecast_ref as string) ? { next_forecast_ref: source.next_forecast_ref as string } : {}),
104
+ forward_forecast_refs: forwardForecastRefs,
105
+ ...(optionalBoundedString(source.execution_ref, 64) && /^[a-f0-9]{64}$/.test(source.execution_ref as string) ? { execution_ref: source.execution_ref as string } : {}),
106
+ data_as_of: dataAsOf,
107
+ },
108
+ actuals: {
109
+ period_complete: typeof evidence?.period_complete === "boolean" ? evidence.period_complete : null,
110
+ reconciled: typeof evidence?.reconciled === "boolean" ? evidence.reconciled : null,
111
+ },
112
+ operating: {
113
+ current_forecast_version: optionalBoundedString(currentCycle?.forecast_version, 256) ?? null,
114
+ execution_status: optionalBoundedString(execution?.status, 256) ?? null,
115
+ next_cycle_status: optionalBoundedString(nextCycle?.status, 256) ?? null,
116
+ next_forecast_version: optionalBoundedString(nextCycle?.forecast_version, 256) ?? null,
117
+ },
118
+ warnings,
119
+ };
120
+ }
121
+
122
+ function verifyLineage(
123
+ summary: DashboardReceiptSummary | undefined,
124
+ expectation: DashboardLineageExpectation,
125
+ ): DashboardLineageVerification {
126
+ if (!/^[a-f0-9]{64}$/.test(expectation.forecast_ref)) throw new Error("expected forecast_ref must be a SHA-256 entry id.");
127
+ if (!summary) return { matches: false, reason: "Dashboard receipt has no verifiable business lineage." };
128
+ if (expectation.forecast_role === "next_plan") {
129
+ if (summary.source.forecast_ref === expectation.forecast_ref) {
130
+ return { matches: false, reason: "Expected next-plan forecast is published as the current forecast." };
131
+ }
132
+ if (summary.source.next_forecast_ref !== expectation.forecast_ref) {
133
+ return { matches: false, reason: "Expected next-plan forecast is not linked as next_forecast_ref." };
134
+ }
135
+ if (!summary.source.forward_forecast_refs.includes(expectation.forecast_ref)) {
136
+ return { matches: false, reason: "Expected next-plan forecast is not present in forward_forecast_refs." };
137
+ }
138
+ if (summary.operating.next_cycle_status !== "ready" && summary.operating.next_cycle_status !== "ready_with_limits") {
139
+ return { matches: false, reason: "Expected next-plan forecast is linked but the next cycle is not ready." };
140
+ }
141
+ return { matches: true, reason: null };
142
+ }
143
+ if (summary.source.forecast_ref !== expectation.forecast_ref) {
144
+ return { matches: false, reason: "Expected forecast is not published as the current forecast." };
145
+ }
146
+ if (summary.source.forecast_role !== expectation.forecast_role) {
147
+ return { matches: false, reason: "Published current forecast role does not match the expected role." };
148
+ }
149
+ return { matches: true, reason: null };
150
+ }
151
+
21
152
  async function readJson(path: string, maxBytes: number): Promise<unknown> {
22
153
  const stat = await lstat(path);
23
154
  if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("must be a regular file, not a symlink");
@@ -25,7 +156,7 @@ async function readJson(path: string, maxBytes: number): Promise<unknown> {
25
156
  return JSON.parse(await readFile(path, "utf8"));
26
157
  }
27
158
 
28
- export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
159
+ export async function inspectDashboard(cwd: string, expectation?: DashboardLineageExpectation): Promise<DashboardStatus> {
29
160
  const dashboardDir = await resolveDashboardDir(cwd);
30
161
  const diagnostics: DashboardStatus["diagnostics"] = [];
31
162
  let manifest: unknown;
@@ -50,6 +181,10 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
50
181
  return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics: [{ target: "manifest.json", message: "unsupported dashboard kind/schema or widgets shape" }] };
51
182
  }
52
183
  const receiptDatasets = new Map<string, string>();
184
+ let projector: DashboardProjectorProvenance | undefined;
185
+ let runtime: DashboardProjectorRuntime | undefined;
186
+ let provenanceStatus: DashboardStatus["provenance_status"];
187
+ let receiptSummary: DashboardReceiptSummary | undefined;
53
188
  const receiptFilename = typeof source.buildReceipt === "string" && DATASET_FILE_RE.test(source.buildReceipt)
54
189
  ? source.buildReceipt
55
190
  : "build-receipt.json";
@@ -62,6 +197,24 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
62
197
  if (receiptSource.kind !== "fpa.dashboard.build" || receiptSource.schema_version !== 1) {
63
198
  throw new Error("has an unsupported build receipt kind or schema");
64
199
  }
200
+ receiptSummary = buildReceiptSummary(receiptSource);
201
+ if (receiptSource.projector === undefined && receiptSource.runtime === undefined) {
202
+ provenanceStatus = "legacy_missing";
203
+ } else {
204
+ provenanceStatus = "verified";
205
+ try {
206
+ projector = validateProjectorProvenance(receiptSource.projector);
207
+ } catch (error) {
208
+ provenanceStatus = "invalid";
209
+ diagnostics.push({ target: `${receiptFilename} projector`, message: error instanceof Error ? error.message : String(error) });
210
+ }
211
+ try {
212
+ runtime = validateProjectorRuntime(receiptSource.runtime);
213
+ } catch (error) {
214
+ provenanceStatus = "invalid";
215
+ diagnostics.push({ target: `${receiptFilename} runtime`, message: error instanceof Error ? error.message : String(error) });
216
+ }
217
+ }
65
218
  if (typeof source.generationId === "string" && receiptSource.generation_id !== source.generationId) {
66
219
  diagnostics.push({ target: receiptFilename, message: "generation does not match manifest.json" });
67
220
  }
@@ -70,15 +223,33 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
70
223
  }
71
224
  const receiptNameMatch = receiptFilename.match(GENERATION_RECEIPT_RE);
72
225
  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" });
226
+ if (typeof source.generationId !== "string" || receiptNameMatch[1] !== source.generationId) {
227
+ diagnostics.push({ target: receiptFilename, message: "filename generation digest does not match manifest.json" });
75
228
  }
76
229
  const receiptDigest = createHash("sha256").update(stableJson(receipt)).digest("hex");
77
- if (receiptNameMatch[2] !== receiptDigest.slice(0, 12)) {
230
+ if (receiptNameMatch[2] !== receiptDigest) {
78
231
  diagnostics.push({ target: receiptFilename, message: "receipt content digest does not match its filename" });
79
232
  }
80
233
  }
81
234
  if (!Array.isArray(receiptSource.datasets)) throw new Error("datasets must be an array");
235
+ if (receiptSource.source && typeof receiptSource.source === "object" && !Array.isArray(receiptSource.source)) {
236
+ const actualsRef = (receiptSource.source as Record<string, unknown>).actuals_snapshot_ref;
237
+ if (actualsRef !== undefined) {
238
+ if (typeof actualsRef !== "string" || !/^[a-f0-9]{64}$/.test(actualsRef)) {
239
+ diagnostics.push({ target: `${receiptFilename} source.actuals_snapshot_ref`, message: "must be a sha256 digest" });
240
+ } else {
241
+ try {
242
+ const snapshot = await readJson(join(dashboardDir, "actuals", `${actualsRef}.json`), 2 * 1024 * 1024);
243
+ const validated = validateActualsSnapshot(snapshot);
244
+ if (createHash("sha256").update(stableJson(validated)).digest("hex") !== actualsRef) {
245
+ diagnostics.push({ target: `actuals/${actualsRef}.json`, message: "Actuals snapshot content digest does not match its immutable ref" });
246
+ }
247
+ } catch (error) {
248
+ diagnostics.push({ target: `actuals/${actualsRef}.json`, message: error instanceof Error ? error.message : String(error) });
249
+ }
250
+ }
251
+ }
252
+ }
82
253
  for (const [index, raw] of receiptSource.datasets.entries()) {
83
254
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
84
255
  diagnostics.push({ target: `${receiptFilename} datasets[${index}]`, message: "must be an object" });
@@ -148,6 +319,11 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
148
319
  dashboard_dir: dashboardDir,
149
320
  ...(typeof source.generationId === "string" ? { generation_id: source.generationId } : {}),
150
321
  ...(typeof source.updatedAt === "string" ? { updated_at: source.updatedAt } : {}),
322
+ ...(projector ? { projector } : {}),
323
+ ...(runtime ? { runtime } : {}),
324
+ ...(provenanceStatus ? { provenance_status: provenanceStatus } : {}),
325
+ ...(receiptSummary ? { receipt_summary: receiptSummary } : {}),
326
+ ...(expectation ? { lineage_verification: verifyLineage(receiptSummary, expectation) } : {}),
151
327
  widget_count: source.widgets.length,
152
328
  diagnostics,
153
329
  };
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.1",
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.