@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.
- package/README.md +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +4 -1
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +85 -4
- package/extensions/fpa-artifacts/store.ts +355 -39
- package/extensions/fpa-dashboard/actuals.ts +215 -51
- package/extensions/fpa-dashboard/coordinator.ts +839 -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 +104 -82
- 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 +118 -1
- package/extensions/fpa-dashboard/status.ts +182 -6
- package/package.json +14 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +1 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +7 -2
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +2 -0
- package/skills/fpa-refresh-dashboard/SKILL.md +9 -1
|
@@ -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,
|
|
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";
|
|
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
|
|
74
|
-
diagnostics.push({ target: receiptFilename, message: "filename generation
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|