@viccydev/pi-fpa 0.4.1 → 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.
- package/README.md +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +417 -0
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +119 -4
- package/extensions/fpa-artifacts/store.ts +376 -50
- package/extensions/fpa-dashboard/actuals.ts +243 -40
- package/extensions/fpa-dashboard/coordinator.ts +831 -0
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +559 -0
- package/extensions/fpa-dashboard/forecast-accuracy.ts +252 -0
- package/extensions/fpa-dashboard/forward-outlook.ts +142 -0
- package/extensions/fpa-dashboard/index.ts +96 -72
- package/extensions/fpa-dashboard/projector.ts +19 -0
- package/extensions/fpa-dashboard/provenance.ts +147 -0
- package/extensions/fpa-dashboard/publisher.ts +99 -9
- package/extensions/fpa-dashboard/service.ts +179 -0
- package/extensions/fpa-dashboard/source.ts +136 -1
- package/extensions/fpa-dashboard/status.ts +56 -5
- package/package.json +14 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +1 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +22 -12
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +55 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +20 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { sliceKey, type Period } from "../fpa-artifacts/contracts.ts";
|
|
4
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
2
5
|
|
|
3
6
|
export interface QueryReceipt {
|
|
4
7
|
dataset: string;
|
|
@@ -18,6 +21,27 @@ export interface DashboardActualSlice {
|
|
|
18
21
|
/** Sentinel used when no Actuals row exists yet, so no calendar date can be reported. */
|
|
19
22
|
export const ACTUALS_DATA_AS_OF_UNAVAILABLE = "unavailable";
|
|
20
23
|
|
|
24
|
+
function canonicalCalendarDate(value: string, label: string): string {
|
|
25
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`${label} must use YYYY-MM-DD.`);
|
|
26
|
+
const parsed = new Date(`${value}T00:00:00Z`);
|
|
27
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
|
28
|
+
throw new Error(`${label} must be a real canonical calendar date.`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function finalPeriodDate(value: Period): string {
|
|
34
|
+
const finalInstant = new Date(Date.parse(value.end_exclusive) - 1);
|
|
35
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
36
|
+
timeZone: value.timezone,
|
|
37
|
+
year: "numeric",
|
|
38
|
+
month: "2-digit",
|
|
39
|
+
day: "2-digit",
|
|
40
|
+
}).formatToParts(finalInstant);
|
|
41
|
+
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
|
42
|
+
return `${byType.get("year")}-${byType.get("month")}-${byType.get("day")}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
/**
|
|
22
46
|
* How Actuals were scoped against the frozen forecast.
|
|
23
47
|
*
|
|
@@ -37,8 +61,32 @@ export interface DashboardDailyPoint {
|
|
|
37
61
|
export interface DashboardActualsSnapshot {
|
|
38
62
|
period: Period;
|
|
39
63
|
data_as_of: string;
|
|
64
|
+
snapshot_evidence: {
|
|
65
|
+
snapshot_id: string | null;
|
|
66
|
+
available_at: string | null;
|
|
67
|
+
consistency: "single_statement" | "multi_query_unverified" | "legacy_unverified";
|
|
68
|
+
/** Explicit close signal emitted by the source ETL; time elapsed is never treated as closure. */
|
|
69
|
+
source_close_signal_id: string | null;
|
|
70
|
+
source_closed_through: string | null;
|
|
71
|
+
source_close_emitted_at: string | null;
|
|
72
|
+
source_close_signal_sha256: string | null;
|
|
73
|
+
period_complete: boolean;
|
|
74
|
+
reconciled: boolean;
|
|
75
|
+
};
|
|
40
76
|
reporting_currency: string;
|
|
41
77
|
scope_mode: DashboardScopeMode;
|
|
78
|
+
/**
|
|
79
|
+
* Whether ua_spend holds any paid rows in the target window at all, ignoring
|
|
80
|
+
* the approved slice keys.
|
|
81
|
+
*
|
|
82
|
+
* A forecast whose slice keys do not exist in the mart produces exactly the
|
|
83
|
+
* same empty Actuals as a cycle that simply has not started yet, and the two
|
|
84
|
+
* are indistinguishable from the scoped queries alone — the scoped query is
|
|
85
|
+
* what determines coverage, so a wrong key erases its own evidence. Probing
|
|
86
|
+
* once without the key predicate separates them: data in the window plus no
|
|
87
|
+
* matching slice means the keys are wrong, not that the period is early.
|
|
88
|
+
*/
|
|
89
|
+
period_has_unscoped_actuals: boolean;
|
|
42
90
|
coverage: {
|
|
43
91
|
date_min: string | null;
|
|
44
92
|
date_max: string | null;
|
|
@@ -100,10 +148,12 @@ function period(value: unknown, path: string): Period {
|
|
|
100
148
|
|
|
101
149
|
export function validateActualsSnapshot(value: unknown): DashboardActualsSnapshot {
|
|
102
150
|
const source = record(value, "actuals");
|
|
151
|
+
const snapshotPeriod = period(source.period, "actuals.period");
|
|
103
152
|
const coverage = record(source.coverage, "actuals.coverage");
|
|
104
153
|
const dailySource = source.daily;
|
|
105
154
|
const slicesSource = source.slices;
|
|
106
155
|
const receiptsSource = source.query_receipts;
|
|
156
|
+
const evidenceSource = source.snapshot_evidence === undefined ? null : record(source.snapshot_evidence, "actuals.snapshot_evidence");
|
|
107
157
|
if (!Array.isArray(dailySource)) throw new Error("actuals.daily must be an array.");
|
|
108
158
|
if (!Array.isArray(slicesSource)) throw new Error("actuals.slices must be an array.");
|
|
109
159
|
if (!Array.isArray(receiptsSource)) throw new Error("actuals.query_receipts must be an array.");
|
|
@@ -143,11 +193,96 @@ export function validateActualsSnapshot(value: unknown): DashboardActualsSnapsho
|
|
|
143
193
|
if (!/^[A-Z]{3}$/.test(reportingCurrency)) throw new Error("actuals.reporting_currency must be an ISO-4217 currency code.");
|
|
144
194
|
const scopeMode = source.scope_mode === undefined ? "app" : string(source.scope_mode, "actuals.scope_mode");
|
|
145
195
|
if (scopeMode !== "app" && scopeMode !== "portfolio") throw new Error('actuals.scope_mode must be "app" or "portfolio".');
|
|
196
|
+
if (source.period_has_unscoped_actuals !== undefined && typeof source.period_has_unscoped_actuals !== "boolean") {
|
|
197
|
+
throw new Error("actuals.period_has_unscoped_actuals must be a boolean.");
|
|
198
|
+
}
|
|
199
|
+
let snapshotEvidence: DashboardActualsSnapshot["snapshot_evidence"] = {
|
|
200
|
+
snapshot_id: null,
|
|
201
|
+
available_at: null,
|
|
202
|
+
consistency: "legacy_unverified",
|
|
203
|
+
source_close_signal_id: null,
|
|
204
|
+
source_closed_through: null,
|
|
205
|
+
source_close_emitted_at: null,
|
|
206
|
+
source_close_signal_sha256: null,
|
|
207
|
+
period_complete: false,
|
|
208
|
+
reconciled: false,
|
|
209
|
+
};
|
|
210
|
+
if (evidenceSource) {
|
|
211
|
+
const consistency = string(evidenceSource.consistency, "actuals.snapshot_evidence.consistency");
|
|
212
|
+
if (consistency !== "single_statement" && consistency !== "multi_query_unverified" && consistency !== "legacy_unverified") {
|
|
213
|
+
throw new Error("actuals.snapshot_evidence.consistency is unsupported.");
|
|
214
|
+
}
|
|
215
|
+
if (evidenceSource.snapshot_id !== null && typeof evidenceSource.snapshot_id !== "string") throw new Error("actuals.snapshot_evidence.snapshot_id must be a string or null.");
|
|
216
|
+
if (evidenceSource.available_at !== null && (typeof evidenceSource.available_at !== "string" || Number.isNaN(Date.parse(evidenceSource.available_at)))) {
|
|
217
|
+
throw new Error("actuals.snapshot_evidence.available_at must be an ISO timestamp or null.");
|
|
218
|
+
}
|
|
219
|
+
if (typeof evidenceSource.period_complete !== "boolean" || typeof evidenceSource.reconciled !== "boolean") {
|
|
220
|
+
throw new Error("actuals.snapshot_evidence period_complete and reconciled must be booleans.");
|
|
221
|
+
}
|
|
222
|
+
const sourceCloseSignalId = evidenceSource.source_close_signal_id === undefined || evidenceSource.source_close_signal_id === null
|
|
223
|
+
? null
|
|
224
|
+
: string(evidenceSource.source_close_signal_id, "actuals.snapshot_evidence.source_close_signal_id");
|
|
225
|
+
const sourceClosedThrough = evidenceSource.source_closed_through === undefined || evidenceSource.source_closed_through === null
|
|
226
|
+
? null
|
|
227
|
+
: string(evidenceSource.source_closed_through, "actuals.snapshot_evidence.source_closed_through");
|
|
228
|
+
if (sourceClosedThrough !== null) canonicalCalendarDate(sourceClosedThrough, "actuals.snapshot_evidence.source_closed_through");
|
|
229
|
+
const sourceCloseEmittedAt = evidenceSource.source_close_emitted_at === undefined || evidenceSource.source_close_emitted_at === null
|
|
230
|
+
? null
|
|
231
|
+
: string(evidenceSource.source_close_emitted_at, "actuals.snapshot_evidence.source_close_emitted_at");
|
|
232
|
+
if (sourceCloseEmittedAt !== null && (Number.isNaN(Date.parse(sourceCloseEmittedAt)) || new Date(sourceCloseEmittedAt).toISOString() !== sourceCloseEmittedAt)) {
|
|
233
|
+
throw new Error("actuals.snapshot_evidence.source_close_emitted_at must be a canonical ISO timestamp or null.");
|
|
234
|
+
}
|
|
235
|
+
const sourceCloseSignalSha256 = evidenceSource.source_close_signal_sha256 === undefined || evidenceSource.source_close_signal_sha256 === null
|
|
236
|
+
? null
|
|
237
|
+
: string(evidenceSource.source_close_signal_sha256, "actuals.snapshot_evidence.source_close_signal_sha256");
|
|
238
|
+
if (sourceCloseSignalSha256 !== null && !/^[a-f0-9]{64}$/.test(sourceCloseSignalSha256)) throw new Error("actuals.snapshot_evidence.source_close_signal_sha256 must be a SHA-256 digest or null.");
|
|
239
|
+
const closeTuple = [sourceCloseSignalId, sourceClosedThrough, sourceCloseEmittedAt, sourceCloseSignalSha256];
|
|
240
|
+
if (closeTuple.some((item) => item !== null) && closeTuple.some((item) => item === null)) {
|
|
241
|
+
throw new Error("actuals.snapshot_evidence source close evidence must be entirely present or entirely null.");
|
|
242
|
+
}
|
|
243
|
+
if (sourceCloseSignalId && sourceClosedThrough && sourceCloseEmittedAt && sourceCloseSignalSha256) {
|
|
244
|
+
const expectedDigest = createHash("sha256").update(stableJson({
|
|
245
|
+
kind: "fpa.actuals.source-close",
|
|
246
|
+
schema_version: 1,
|
|
247
|
+
dataset: "ua_spend",
|
|
248
|
+
signal_id: sourceCloseSignalId,
|
|
249
|
+
closed_through: sourceClosedThrough,
|
|
250
|
+
emitted_at: sourceCloseEmittedAt,
|
|
251
|
+
})).digest("hex");
|
|
252
|
+
if (sourceCloseSignalSha256 !== expectedDigest) throw new Error("actuals.snapshot_evidence source close digest does not match its canonical evidence.");
|
|
253
|
+
}
|
|
254
|
+
if (evidenceSource.period_complete && (!sourceCloseSignalId || !sourceClosedThrough || !sourceCloseEmittedAt || !sourceCloseSignalSha256)) {
|
|
255
|
+
throw new Error("period_complete requires complete explicit source close evidence.");
|
|
256
|
+
}
|
|
257
|
+
if (evidenceSource.period_complete && (
|
|
258
|
+
consistency !== "single_statement"
|
|
259
|
+
|| evidenceSource.available_at === null
|
|
260
|
+
|| (sourceClosedThrough as string) < finalPeriodDate(snapshotPeriod)
|
|
261
|
+
|| Date.parse(evidenceSource.available_at as string) < Date.parse(sourceCloseEmittedAt as string)
|
|
262
|
+
)) {
|
|
263
|
+
throw new Error("period_complete requires single-statement evidence observed after a source close signal covering the target period.");
|
|
264
|
+
}
|
|
265
|
+
snapshotEvidence = {
|
|
266
|
+
snapshot_id: evidenceSource.snapshot_id as string | null,
|
|
267
|
+
available_at: evidenceSource.available_at as string | null,
|
|
268
|
+
consistency,
|
|
269
|
+
source_close_signal_id: sourceCloseSignalId,
|
|
270
|
+
source_closed_through: sourceClosedThrough,
|
|
271
|
+
source_close_emitted_at: sourceCloseEmittedAt,
|
|
272
|
+
source_close_signal_sha256: sourceCloseSignalSha256,
|
|
273
|
+
period_complete: evidenceSource.period_complete,
|
|
274
|
+
reconciled: evidenceSource.reconciled,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
146
277
|
return {
|
|
147
|
-
period:
|
|
278
|
+
period: snapshotPeriod,
|
|
148
279
|
data_as_of: string(source.data_as_of, "actuals.data_as_of"),
|
|
280
|
+
snapshot_evidence: snapshotEvidence,
|
|
149
281
|
reporting_currency: reportingCurrency,
|
|
150
282
|
scope_mode: scopeMode,
|
|
283
|
+
// Absent means the probe was never run, which is only ever the benign
|
|
284
|
+
// reading: nothing here may invent evidence that the keys are wrong.
|
|
285
|
+
period_has_unscoped_actuals: source.period_has_unscoped_actuals === true,
|
|
151
286
|
coverage: {
|
|
152
287
|
date_min: coverage.date_min === null ? null : string(coverage.date_min, "actuals.coverage.date_min"),
|
|
153
288
|
date_max: coverage.date_max === null ? null : string(coverage.date_max, "actuals.coverage.date_max"),
|
|
@@ -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,
|
|
10
|
-
const GENERATION_RECEIPT_RE = /^build-receipt\.([a-f0-9]{
|
|
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
|
|
74
|
-
diagnostics.push({ target: receiptFilename, message: "filename generation
|
|
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
|
|
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.
|
|
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/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/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,21 +21,31 @@ 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
|
-
6.
|
|
29
|
-
7.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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.
|
|
35
|
+
|
|
36
|
+
## Write the decision, not the arithmetic
|
|
37
|
+
|
|
38
|
+
The only judgement in a forecast is the allocation and each slice's ROAS
|
|
39
|
+
assumption. Revenue, the consolidated roll-up, units, windows, and `frozen_at`
|
|
40
|
+
all follow from those, and `fpa_forecast_compose` derives them — which is what
|
|
41
|
+
makes the identities the commit checks true by construction rather than
|
|
42
|
+
dependent on transcribing several hundred numbers without a slip.
|
|
43
|
+
|
|
44
|
+
So do not hand-compute revenue, totals, or ratios, and do not emit the full
|
|
45
|
+
artifact as one tool argument: that is the shape that gets cut off at the model
|
|
46
|
+
output limit and leaves a turn that reads finished but committed nothing. If a
|
|
47
|
+
later node in this graph composes and commits for you, stop after writing the
|
|
48
|
+
plan and say so; never claim a fingerprint you were not handed.
|
|
39
49
|
|
|
40
50
|
## Status and the publish gate
|
|
41
51
|
|
|
@@ -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
|
|
@@ -45,11 +46,61 @@ frozen_at: timestamp
|
|
|
45
46
|
|
|
46
47
|
Do not include `immutable_fingerprint` in the tool input; a draft that carries one is rejected. `fpa_artifact_commit` validates exact fields, approval state, allocation totals, every scenario's slice-to-consolidated totals, and supplied ROAS against aggregated revenue/spend. It computes the fingerprint and returns it after durable storage. The agent must not claim the artifact is frozen until that tool succeeds.
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
## Author the plan, not the artifact
|
|
49
50
|
|
|
51
|
+
Almost none of the artifact above is a decision. The decision is the allocation
|
|
52
|
+
and the ROAS assumption behind each slice; revenue, the ROAS write-back, the
|
|
53
|
+
consolidated roll-up, every unit and window string, and `frozen_at` all follow
|
|
54
|
+
from those by arithmetic. Write the plan and let `fpa_forecast_compose` derive
|
|
55
|
+
the rest — it makes the identities the commit checks true by construction
|
|
56
|
+
instead of true if you typed them correctly.
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
# artifacts/forecast_plan.json
|
|
60
|
+
status: complete | complete_with_limits | blocked
|
|
61
|
+
forecast_version: string
|
|
62
|
+
strategy_version: string
|
|
63
|
+
strategy_review_id: string
|
|
64
|
+
human_approval_id: string
|
|
65
|
+
approval_conditions_satisfied: boolean
|
|
66
|
+
target_period: {start_inclusive: timestamp, end_exclusive: timestamp, timezone: string}
|
|
67
|
+
data_as_of: timestamp
|
|
68
|
+
source_snapshot_ids: []
|
|
69
|
+
assumption_version: string
|
|
70
|
+
model_version: string
|
|
71
|
+
reporting_currency: string
|
|
72
|
+
calibration_policy: {stop_loss_roas_lt, deviation_warning_abs_gte, deviation_trigger_abs_gt, policy_version}
|
|
73
|
+
unsupported_metrics: []
|
|
74
|
+
reconciliation_checks: []
|
|
75
|
+
slices:
|
|
76
|
+
- app_id: string # ua_spend.app_code
|
|
77
|
+
store: string # ua_spend.platform — ios | android, never a store name
|
|
78
|
+
channel_group: string # ua_spend.media_source, verbatim
|
|
79
|
+
baseline_spend: number | null
|
|
80
|
+
approved_spend: number
|
|
81
|
+
action: stop | decrease | hold | increase | explore
|
|
82
|
+
owner: string # optional; shown as the accountable operator on the CEO dashboard
|
|
83
|
+
roas: {downside: number|null, base: number|null, upside: number|null}
|
|
84
|
+
consolidated_extra_metrics: # optional; measured portfolio series only
|
|
85
|
+
metric_name: {downside, base, upside, unit}
|
|
50
86
|
```
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
|
|
88
|
+
Then:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
write artifacts/forecast_plan.json
|
|
92
|
+
fpa_forecast_compose { "plan_path": "artifacts/forecast_plan.json" }
|
|
93
|
+
fpa_artifact_commit { "artifact": <the artifact compose returned> }
|
|
53
94
|
```
|
|
54
95
|
|
|
55
|
-
|
|
96
|
+
Rules the plan has to respect, because compose enforces them:
|
|
97
|
+
|
|
98
|
+
- **Spend is a decision, not a prediction.** It is identical in all three scenarios, so a slice carries one `approved_spend`. Only revenue moves, through `roas`.
|
|
99
|
+
- **A stopped slice has no ROAS.** `approved_spend: 0` means revenue 0 and `roas` null in all three scenarios — the contract derives ROAS as revenue/spend and calls a zero denominator null.
|
|
100
|
+
- **Slice keys must be values `ua_spend` actually uses.** A plan naming `google_play` where the mart says `android` is rejected with the valid values listed. The single-placeholder App axis for a scoped-out portfolio is the one permitted exception.
|
|
101
|
+
- **`status` and `unsupported_metrics` must agree.** `complete` requires an empty list; `complete_with_limits` requires a non-empty one. Scope excluded upstream is neither.
|
|
102
|
+
- **Do not hand-compute anything derived.** A ROAS rounded to four decimals misses the commit tolerance and costs a re-draft.
|
|
103
|
+
|
|
104
|
+
## Committing an artifact you already hold
|
|
105
|
+
|
|
106
|
+
`fpa_artifact_commit` also takes `artifact_path` for an artifact written to disk, and `artifact` for one passed inline. Both are project-relative and fail closed. Emitting a full artifact by hand in a single tool call is what gets cut off at the model output limit — prefer the plan above.
|
|
@@ -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.
|
|
@@ -20,10 +24,25 @@ Require a committed `approved_cycle_forecast` for the target project. Treat a co
|
|
|
20
24
|
5. Publish only when the user explicitly asked to rebuild or publish the dashboard. Call the same tool with `mode: publish` and the exact preview fingerprint.
|
|
21
25
|
6. Report the published generation, source forecast fingerprint, data cutoff, warnings, and dashboard path.
|
|
22
26
|
|
|
27
|
+
## When preview returns `blocked_slice_key_mismatch`
|
|
28
|
+
|
|
29
|
+
Every approved slice is missing its Actuals while the target window demonstrably
|
|
30
|
+
holds paid rows. That is not an early cycle and not a scope limitation: the
|
|
31
|
+
frozen forecast is keyed on dimension values `ua_spend` never uses, so the
|
|
32
|
+
dashboard would publish complete-looking and entirely empty. Publish is refused.
|
|
33
|
+
|
|
34
|
+
Report it as a failure, name the offending key axis, and send the work back to
|
|
35
|
+
the node that froze the forecast. Do not re-preview hoping for a different
|
|
36
|
+
answer, and do not describe it as "the period has no data yet".
|
|
37
|
+
|
|
23
38
|
## Boundaries
|
|
24
39
|
|
|
25
40
|
- Never write `.fpa-dashboard` with generic file or shell tools.
|
|
26
41
|
- Never fabricate Actuals, replace missing values with zero, or average row-level ratios.
|
|
27
42
|
- Do not edit the approved forecast during projection.
|
|
28
43
|
- If inputs change between preview and publish, preview again rather than bypassing the fingerprint check.
|
|
29
|
-
- This skill performs one bounded refresh
|
|
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.
|